diff --git a/docs/cli/me-claude.md b/docs/cli/me-claude.md index 34284b22..bee8690a 100644 --- a/docs/cli/me-claude.md +++ b/docs/cli/me-claude.md @@ -30,7 +30,7 @@ claude plugin install --scope user memory-engine@memory-engine \ [--config server=] [--config space=] [--config api_key=] ``` -The marketplace step is idempotent (skipped if already configured). **By default nothing is pinned into the plugin** -- `server`, `space`, and `api_key` are left blank so the plugin (hooks + MCP) tracks your live `me` config at runtime: your `me login` server, active space, and session. Pinning is opt-in: `--server` / `--space` pin those, and `--api-key` marks a headless install (see below). After install, restart Claude Code (or run `/plugin`) to load the hooks and slash commands. +The marketplace step is idempotent (skipped if already configured). **By default nothing is pinned into the plugin** -- `server`, `space`, and `api_key` are left blank. Hooks track your live `me` config, including the active space for captures; MCP uses your live server and session but is multi-space until `space`, `--space`, or `ME_SPACE` is set. Pinning is opt-in: `--server` / `--space` pin those, and `--api-key` marks a headless install (see below). After install, restart Claude Code (or run `/plugin`) to load the hooks and slash commands. A session (non-headless) install then: @@ -43,11 +43,11 @@ Pass `--mcp-only` to skip the plugin and register just the `me` MCP server (no h |--------|-------------| | `--mcp-only` | Register only the `me` MCP server (no hooks or slash commands). | | `--api-key ` | API key for a headless install. Default: the plugin/MCP server uses your `me login` session, resolved at runtime. | -| `--space ` | Pin a space. Default: resolve `ME_SPACE` / active space at runtime. | +| `--space ` | Lock MCP to this space and pin captures. Without it, MCP is multi-space unless `ME_SPACE` is set. | | `--server ` | Pin a server. Default: use your `me login` server at runtime. | | `--dev` | Install the plugin from the local checkout instead of the published marketplace (run from inside this repo). | -Credential handling: by default (a personal install) nothing is pinned, so the plugin (and the MCP server) uses your `me login` session, server, and active space, resolved from the OS keychain / `~/.config/me` at runtime — so it follows `me login` / `me space use` and survives re-login. Pass `--server` / `--space` to pin either. Pass `--api-key` for a **headless** install that can't reach your keychain — since there's no session to fall back to, an API key bakes in a fixed server + space + key together (and skips the defaults/capture steps above — the operator's `~/.config/me` is not necessarily the tool's; capture is credential-agnostic, so a headless deployment opts in via a committed `.me` `capture: true` or `capture: true` in the target machine's config). For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; the pinned space must be declared by that key. The space is resolved from `--space`, `ME_SPACE`, or your active space (whichever is set — install errors if none, since a global key has no active space to fall back to at runtime), and `--server` defaults to your resolved server. +Credential handling: by default (a personal install) nothing is pinned, so the plugin and MCP server use your `me login` session and server, resolved from the OS keychain / `~/.config/me` at runtime. An unpinned MCP server is multi-space: agents call `me_space_list`, then pass `space` to every memory tool. Your active space controls captures, not MCP routing. Pass `--server` / `--space` to pin either; a pinned space locks the MCP tools to that space. Pass `--api-key` for a **headless** install that cannot reach your keychain; it may also run multi-space. Capture is credential-agnostic, so a headless deployment opts in through a committed `.me` `capture: true` or the target machine's config. For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; a pinned space must be declared by that key. `ME_SPACE` also locks MCP at runtime, and `--server` defaults to your resolved server. There is no `--scope` flag: the plugin is always installed at **user** scope (once, for all projects). Per-project behavior — a shared tree, a pinned space, and capture on/off — comes from the committed [`.me/config.yaml`](../project-config.md), which the single installed plugin reads per project. @@ -82,7 +82,7 @@ claude plugin install --scope user memory-engine@memory-engine /plugin # select memory-engine, Configure (all values optional if logged in) ``` -Both `api_key` and `space` are optional: blank `api_key` uses your `me login` session, and blank `space` uses your active space (`me space use`; pin it for project/shared installs). +Both `api_key` and `space` are optional: blank `api_key` uses your `me login` session. Blank `space` leaves MCP multi-space; it does not use your active space. Set `space` to lock MCP and pin capture for a project/shared install. If you only want the MCP tools (no hooks, no slash commands), run [me claude install --mcp-only](#me-claude-install) instead. diff --git a/docs/cli/me-codex.md b/docs/cli/me-codex.md index 69c51c4c..b9b364e5 100644 --- a/docs/cli/me-codex.md +++ b/docs/cli/me-codex.md @@ -21,10 +21,10 @@ me codex install [options] | Option | Description | |--------|-------------| | `--api-key ` | API key for a headless install. Default: the MCP server uses your `me login` session, resolved at runtime. | -| `--space ` | Pin a space. Default: resolve `ME_SPACE` / active space at runtime. | +| `--space ` | Lock MCP to this space. Without it, MCP is multi-space unless `ME_SPACE` is set. | | `--server ` | Server URL to embed in the MCP config. | -By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` for a headless install that cannot reach your keychain; that bakes the key and requires a pinned `--space`. For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; the pinned space must be declared by that key. +By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login). MCP is multi-space unless the generated command includes `--space` or its environment sets `ME_SPACE`: agents call `me_space_list`, then pass `space` to every memory tool. Your active space does not select an MCP space. Pass `--space` to lock the MCP tools to one space. Pass `--api-key` for a headless install that cannot reach your keychain; it may also run multi-space. For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; a pinned space must be declared by that key. `me codex install` also adds a hook to `~/.codex/hooks.json` so that a plain `me` call from Codex's shell can discover the active project after a directory change. Re-running install is safe and leaves any other hooks you've configured untouched. **One-time step**: Codex holds new hooks for review — run `/hooks` inside Codex once to approve it. @@ -32,7 +32,7 @@ For manual MCP client configuration, see [MCP Integration](../mcp-integration.md ### Known gap: Codex Desktop and the VS Code extension -Under the Codex **terminal CLI**, `me mcp` resolves your project the ordinary way — no action needed. The Codex **Desktop app** and **VS Code extension** currently launch MCP servers from the wrong working directory, so `me mcp` can't tell which project you're in and falls back to your global config (your own session and active space) instead of the project's `.me/config.yaml`. The workaround is to set a per-server `cwd` pointing at your project directory in Codex's own MCP config. The terminal CLI is unaffected. +Under the Codex **terminal CLI**, `me mcp` resolves your project the ordinary way — no action needed. The Codex **Desktop app** and **VS Code extension** currently launch MCP servers from the wrong working directory, so `me mcp` cannot use a project's `.me/config.yaml` for server resolution and falls back to global configuration. MCP space selection is unaffected: an unpinned server is always multi-space. The workaround is to set a per-server `cwd` pointing at your project directory in Codex's own MCP config. The terminal CLI is unaffected. --- diff --git a/docs/cli/me-gemini.md b/docs/cli/me-gemini.md index c8ec4170..4575e8a4 100644 --- a/docs/cli/me-gemini.md +++ b/docs/cli/me-gemini.md @@ -20,11 +20,11 @@ me gemini install [options] | Option | Description | |--------|-------------| | `--api-key ` | API key for a headless install. Default: the MCP server uses your `me login` session, resolved at runtime. | -| `--space ` | Pin a space. Default: resolve `ME_SPACE` / active space at runtime. | +| `--space ` | Lock MCP to this space. Without it, MCP is multi-space unless `ME_SPACE` is set. | | `--server ` | Server URL to embed in the MCP config. | | `-s, --scope ` | Gemini CLI config scope: `user` or `project`. Default: `user`. | -By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` for a headless install that cannot reach your keychain; that bakes the key and requires a pinned `--space`. For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; the pinned space must be declared by that key. +By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login). MCP is multi-space unless the generated command includes `--space` or its environment sets `ME_SPACE`: agents call `me_space_list`, then pass `space` to every memory tool. Your active space does not select an MCP space. Pass `--space` to lock the MCP tools to one space. Pass `--api-key` for a headless install that cannot reach your keychain; it may also run multi-space. For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; a pinned space must be declared by that key. `me gemini install` also adds a small hook to `~/.gemini/settings.json` so that a plain `me` call from Gemini CLI's shell tool can discover the active project after a directory change. Re-running install is safe and leaves any other hooks you've configured untouched. diff --git a/docs/cli/me-mcp.md b/docs/cli/me-mcp.md index 8c99c54e..e091ef15 100644 --- a/docs/cli/me-mcp.md +++ b/docs/cli/me-mcp.md @@ -21,15 +21,15 @@ me mcp [options] | Option | Description | |--------|-------------| | `--api-key ` | API key. If omitted, the server uses your stored `me login` session. | -| `--space ` | Space to operate in (the `X-Me-Space`). | +| `--space ` | Lock MCP to this space (the `X-Me-Space`). | Resolution order: - **Auth token**: `--api-key` > `ME_API_KEY` > stored session token. -- **Space**: `--space` > `ME_SPACE` > stored active space. +- **Space**: `--space` > `ME_SPACE`; without either, MCP is multi-space. - **Server URL**: `--server` (global option) > `ME_SERVER` > `https://api.memory.build`. -A logged-in developer needs no key or space — the active session and active space are used automatically. For an unattended or restricted installation, pass `--api-key` and `--space` (or set `ME_API_KEY` / `ME_SPACE`). The server acts as the principal represented by that credential. +A logged-in developer needs no key or space. `--space` and `ME_SPACE` create a locked server: memory tools cannot select another space. Without either, MCP starts multi-space mode: `me_space_list` is available and memory tools require `space`. Project configuration and your stored active space do not select an MCP space. This supports manual configuration without `.me/config.yaml` or an active space, for example `me mcp --api-key --server `. Selecting a space never grants access; the server still enforces membership and tree grants. The server acts as the principal represented by that credential. This command is typically not run directly -- it is invoked by AI tools based on their MCP configuration. @@ -53,4 +53,4 @@ claude plugin marketplace add timescale/memory-engine claude plugin install memory-engine@memory-engine [--scope user|project|local] ``` -Then start Claude Code, run `/plugin`, select `memory-engine`, and configure the options (all optional): leave `api_key`, `server`, and `space` blank to fall back to your `me login` session, server, and active space; set `content_mode` to control how much of each message is captured. The plugin has no tree setting — where captured sessions are stored is controlled by your project's [`.me/config.yaml`](../project-config.md) `tree` (or the private `~/projects` default). See [`me claude`](me-claude.md) for the full plugin reference. +Then start Claude Code, run `/plugin`, select `memory-engine`, and configure the options (all optional): leave `api_key` and `server` blank to use your `me login` session and server; leave `space` blank for multi-space MCP tools or set it to lock MCP and pin captures. Set `content_mode` to control how much of each message is captured. The plugin has no tree setting — where captured sessions are stored is controlled by your project's [`.me/config.yaml`](../project-config.md) `tree` (or the private `~/projects` default). See [`me claude`](me-claude.md) for the full plugin reference. diff --git a/docs/cli/me-opencode.md b/docs/cli/me-opencode.md index 86ed29f0..6e6c413a 100644 --- a/docs/cli/me-opencode.md +++ b/docs/cli/me-opencode.md @@ -24,11 +24,11 @@ A session (non-headless) install ends with the shared **capture prompt** (defaul | Option | Description | |--------|-------------| | `--api-key ` | API key for a headless install. Default: the MCP server uses your `me login` session, resolved at runtime. | -| `--space ` | Pin a space. Default: resolve `ME_SPACE` / active space at runtime. | +| `--space ` | Lock MCP to this space. Without it, MCP is multi-space unless `ME_SPACE` is set. | | `--server ` | Server URL to embed in the MCP config. | | `--scope ` | Where to write the config: `project` (`./opencode.json` at the repo root) or `user` (`~/.config/opencode/opencode.json`). Default: `user`. | -By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login) and your active space (set by `me space use` / `ME_SPACE`). Pass `--api-key` for a headless install that cannot reach your keychain; that bakes the key and requires a pinned `--space`. For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; the pinned space must be declared by that key. +By default only the server URL is baked into the config: at runtime `me mcp` uses your `me login` session (resolved from the OS keychain / `~/.config/me` each run, so it survives re-login). MCP is multi-space unless the generated command includes `--space` or its environment sets `ME_SPACE`: agents call `me_space_list`, then pass `space` to every memory tool. Your active space does not select an MCP space. Pass `--space` to lock the MCP tools to one space. Pass `--api-key` for a headless install that cannot reach your keychain; it may also run multi-space. For least privilege, mint a restricted PAT or service-account key with `me apikey create --allow ::`; a pinned space must be declared by that key. Use `--scope project` to write the `mcp.me` entry into the repo's `opencode.json` (instead of your global config) so it can be committed and shared with your team. Don't combine `--scope project` with a baked `--api-key` unless you intend to commit that key. diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index 65ef5c94..6279b3f8 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -1,6 +1,6 @@ # MCP Integration -Memory Engine integrates with AI coding agents via the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP). This gives agents 14 memory tools they can use to store and retrieve knowledge across conversations. +Memory Engine integrates with AI coding agents via the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP). This gives agents 15 memory tools they can use to store and retrieve knowledge across conversations. ## How it works @@ -14,15 +14,15 @@ When an AI tool launches `me mcp`, it spawns a child process that communicates o The AI agent never sees or handles credentials — it just calls MCP tools and gets results back. -Each `me mcp` instance is locked to a single **space**, carried as the `X-Me-Space` header. The space is resolved from `--space` > `ME_SPACE` > your stored active space. Authentication is either an API key (`--api-key` or `ME_API_KEY`: a user PAT or service-account key) or, if no key is given, your stored `me login` session token — so a developer install needs no key at all. A restricted PAT or service-account key must declare the pinned space. The server URL defaults to `https://api.memory.build` but can be overridden with `--server` or `ME_SERVER`. +Each `me mcp` instance uses one of two space modes. An explicit `--space` or `ME_SPACE` creates a **locked** server: memory tools do not expose a `space` parameter and every call uses that space. Without either selector, it starts in **multi-space** mode: `me_space_list` is available and every memory tool requires `space`. Project configuration and your stored active space never select an MCP space. This makes manual MCP setup work without any local Memory Engine configuration. Authentication is either an API key (`--api-key` or `ME_API_KEY`: a user PAT or service-account key) or, if no key is given, your stored `me login` session token — so a developer install needs no key at all. The server URL defaults to `https://api.memory.build` but can be overridden with `--server` or `ME_SERVER`. -MCP calls always run as the principal represented by the presented credential. A normal local install uses your login session. For an unattended or restricted installation, pass a restricted PAT or service-account key and expose no stronger credential to that process. +MCP calls always run as the principal represented by the presented credential. A normal local install uses your login session. For an unattended or restricted installation, pass a restricted PAT or service-account key and expose no stronger credential to that process. A per-tool `space` only selects the target space; the server still checks membership and the credential's grants, including restricted-key declarations. ## Setup ### Prerequisites -Log in with `me login` and select a space — `me whoami` shows your active space and identity. That session is enough to run the MCP server locally. For an unattended install, mint an API key and pass it with `--api-key`. For least privilege, use a restricted PAT or service-account key, for example `me apikey create mcp --allow :/share/project:w`. +Log in with `me login` to run the MCP server locally. Selecting a space is optional: omitting it starts multi-space mode. For an unattended installation, mint an API key and pass it with `--api-key`. For least privilege, use a restricted PAT or service-account key, for example `me apikey create mcp --allow :/share/project:w`. The server defaults to `https://api.memory.build`. Pass `--server ` only if you're running a self-hosted server. @@ -36,6 +36,18 @@ me gemini install These commands register Memory Engine with the named tool, writing a `me mcp` invocation into the tool's MCP configuration. By default they embed no key — the server uses your `me login` session at runtime. Pass `--api-key` to pin a user PAT or service-account key instead, `--space ` to pin a space, and `--server ` to pin a non-default server. For a restricted key, the pinned space must be one of its declarations. +### Manual multi-space setup + +You can configure any stdio MCP client directly, without running a harness installer or creating a project config: + +```bash +me mcp --server --api-key +``` + +This starts multi-space mode. Call `me_space_list` first, then pass one +of its slugs as the required `space` argument to a memory tool. Add `--space +` only when you want to lock that MCP process to one space. + See the agent-specific command references for details: [`me opencode install`](cli/me-opencode.md#me-opencode-install), [`me codex install`](cli/me-codex.md#me-codex-install), and [`me gemini install`](cli/me-gemini.md#me-gemini-install). [`me project init`](cli/me-project.md) goes further than any of the installers above: it's a harness-agnostic, per-project wizard that backfills existing sessions (for whichever of Claude Code/OpenCode/Codex actually has any), enables the per-project capture flag used by the Claude Code and OpenCode capture hooks, sets up the CI import workflow, and writes a memory pointer into `CLAUDE.md`/`AGENTS.md`. Its preflight also offers to run `me claude install`/`me opencode install` for you if a harness is detected but not yet set up. @@ -145,10 +157,12 @@ After saving, check the Agent Panel settings — the indicator next to "me" shou Any tool that supports the MCP stdio transport can use Memory Engine. The server command is: ```bash -me mcp --api-key --space --server +me mcp --api-key --server ``` -Point your client at this command with `stdio` as the transport type. +Point your client at this command with `stdio` as the transport type. This is +multi-space mode: call `me_space_list`, then pass `space` to each memory +tool. Add `--space ` to lock the server to one space instead. ## Available tools @@ -156,6 +170,7 @@ Once connected, the agent has access to: | Tool | Purpose | |------|---------| +| `me_space_list` | List spaces available for per-call selection (multi-space mode only) | | `me_memory_context` | Show current identity, active space, and effective access | | `me_memory_create` | Store a new memory | | `me_memory_search` | Search by meaning, keywords, or filters | diff --git a/docs/mcp/agent-instructions.md b/docs/mcp/agent-instructions.md index 5e5c2cb2..9b4cc981 100644 --- a/docs/mcp/agent-instructions.md +++ b/docs/mcp/agent-instructions.md @@ -60,9 +60,12 @@ Example filtered browse: ## Understand Access -Each MCP server runs in one active space as one authenticated principal: a user -or service account. What you can see or change depends on that principal's -tree-access grants in the active space. +Each MCP server runs as one authenticated principal: a user or service account. +It is either locked to one space or multi-space. In multi-space mode, +`me_space_list` is available; use it to discover a slug before passing the +required `space` to a memory tool. +What you can see or change depends on that principal's tree-access grants in +the selected space. Access is path-based and hierarchical: @@ -74,9 +77,10 @@ Access filtering is quiet. If you lack `read` on a memory's tree path, search ma return fewer results and retrieval may report `not found`. If you lack `write`, creating or changing a memory in that tree fails even if the tree exists. -Call `me_memory_context` when you need to confirm the current space, acting -principal, or effective tree-access grants. The access list shows the paths you -can actually read, write, or own, including inherited group access. +Call `me_memory_context` when you need to confirm the selected space, acting +principal, or effective tree-access grants. In multi-space mode, pass the same +`space` you intend to use for a memory operation. The access list shows the +paths you can actually read, write, or own, including inherited group access. Do not assume every space has the same layout or grants. Some spaces use `/share/...` for team knowledge and `~/...` for private notes, but custom spaces diff --git a/docs/mcp/index.md b/docs/mcp/index.md index ca307cf8..234ad936 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -1,6 +1,6 @@ # MCP Tool Reference -Memory Engine exposes 15 tools to AI agents over the [Model Context Protocol](https://modelcontextprotocol.io/). Once an agent is connected (see [MCP Integration](../mcp-integration.md)), it can inspect its context, store, search, and organize memories with the tools below. +Memory Engine exposes 15 memory tools to AI agents over the [Model Context Protocol](https://modelcontextprotocol.io/). In multi-space mode it also exposes a space-discovery tool. Once an agent is connected (see [MCP Integration](../mcp-integration.md)), it can inspect its context, store, search, and organize memories with the tools below. If you are an agent using these tools, start with [MCP Agent Instructions](agent-instructions.md) for when to search, what to store, and how access control affects visible and writable trees. @@ -10,6 +10,12 @@ If you are an agent using these tools, start with [MCP Agent Instructions](agent |------|---------| | [`me_memory_context`](me_memory_context.md) | Show current identity, active space, and effective access | +## Spaces + +| Tool | Purpose | +|------|---------| +| [`me_space_list`](me_space_list.md) | List spaces available for per-call selection (multi-space mode only) | + ## Storing and editing | Tool | Purpose | diff --git a/docs/mcp/me_memory_context.md b/docs/mcp/me_memory_context.md index 58c72eee..6e2b81f6 100644 --- a/docs/mcp/me_memory_context.md +++ b/docs/mcp/me_memory_context.md @@ -2,18 +2,19 @@ Inspect the current Memory Engine execution context. -Use this tool when you need to confirm which server, space, and principal the MCP server is using, or when you need to choose a readable or writable tree path. +Use this tool when you need to confirm which server, selected space, and principal the MCP server is using, or when you need to choose a readable or writable tree path. ## Parameters -This tool takes no parameters. +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | ## Returns ```json { "server": "https://api.memory.build", - "activeSpace": "6nnv8r3gz9jr", "mode": "user", "space": { "id": "019f...", @@ -39,9 +40,9 @@ This tool takes no parameters. | Field | Type | Description | |-------|------|-------------| | `server` | `string` | Server URL configured for this MCP server. | -| `activeSpace` | `string` | Active space slug sent as `X-Me-Space`. | +| `activeSpace` | `string` | Present only in locked mode: the space slug sent as `X-Me-Space` for every call. | | `mode` | `string` | `user` or `service-account`. | -| `space` | `object` | Active space id, slug, and display name. | +| `space` | `object` | Server-confirmed space id, slug, and display name for this call. In multi-space mode, this is the selected context. | | `principal` | `object` | Principal the memory tools are acting as. `kind` is `u` for user or `s` for service account. | | `access` | `array` | Effective tree access paths for the acting principal. | diff --git a/docs/mcp/me_memory_copy.md b/docs/mcp/me_memory_copy.md index 83fbe6ef..1e28b31d 100644 --- a/docs/mcp/me_memory_copy.md +++ b/docs/mcp/me_memory_copy.md @@ -8,6 +8,7 @@ The source memories are preserved. Copied memories receive new IDs. | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `source` | `string` | yes | Source tree prefix to copy from. Must be non-empty. | | `destination` | `string` | yes | Destination tree prefix to copy to. Must be non-empty. | | `dry_run` | `boolean` | yes | If `true`, return the count without copying. If `false`, execute the copy. | diff --git a/docs/mcp/me_memory_count.md b/docs/mcp/me_memory_count.md index d662f216..7db5d720 100644 --- a/docs/mcp/me_memory_count.md +++ b/docs/mcp/me_memory_count.md @@ -8,6 +8,7 @@ The `tree` input is required and supports an exact path prefix, a wildcard patte | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `tree` | `string` | yes | Tree filter: an exact path prefix (for example, `/share/projects`), a wildcard pattern (for example, `*/api/*`), or a label search (for example, `api & v2`). | | `max_count` | `integer \| null` | no | Stop counting after this many matches. If the returned `count` equals `max_count`, treat the result as "at least `max_count`" rather than an exact total. | diff --git a/docs/mcp/me_memory_create.md b/docs/mcp/me_memory_create.md index dfcf4002..3ac667e3 100644 --- a/docs/mcp/me_memory_create.md +++ b/docs/mcp/me_memory_create.md @@ -6,6 +6,7 @@ Store a new memory. | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `id` | `string \| null` | no | UUIDv7 to preserve identity (import/export). Omit or pass `null` to auto-generate. | | `content` | `string` | yes | The content of the memory. Must be non-empty. | | `name` | `string \| null` | no | Optional filename-like leaf slug, unique within the tree (e.g. `jwt-rotation`). Matches `^[A-Za-z0-9][A-Za-z0-9._-]*$`, ≤128 chars -- dots allowed, no slashes. Lets the memory be addressed as `/share/auth/jwt-rotation`. Omit or pass `null` for an unnamed memory. | diff --git a/docs/mcp/me_memory_delete.md b/docs/mcp/me_memory_delete.md index 0d6d90ea..641da084 100644 --- a/docs/mcp/me_memory_delete.md +++ b/docs/mcp/me_memory_delete.md @@ -8,6 +8,7 @@ This is irreversible. Consider archiving (via a meta update) or moving (via `me_ | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `id` | `string` | yes | The UUID of the memory to delete. | ## Returns diff --git a/docs/mcp/me_memory_delete_by_path.md b/docs/mcp/me_memory_delete_by_path.md index 94da0ca4..8ea07adb 100644 --- a/docs/mcp/me_memory_delete_by_path.md +++ b/docs/mcp/me_memory_delete_by_path.md @@ -10,6 +10,7 @@ whole subtree, or `me_memory_delete` to delete by UUID. | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `path` | `string` | yes | The `tree/name` path, e.g. `/share/auth/jwt-rotation`. | ## Returns diff --git a/docs/mcp/me_memory_delete_tree.md b/docs/mcp/me_memory_delete_tree.md index b07ac43f..475732c3 100644 --- a/docs/mcp/me_memory_delete_tree.md +++ b/docs/mcp/me_memory_delete_tree.md @@ -8,6 +8,7 @@ Use `dry_run: true` to preview how many memories would be deleted without actual | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `tree` | `string` | yes | Tree prefix -- all memories at or below this path will be deleted. Must be non-empty. | | `dry_run` | `boolean` | yes | If `true`, return the count without deleting. If `false`, execute the deletion. | diff --git a/docs/mcp/me_memory_export.md b/docs/mcp/me_memory_export.md index 2baace94..fa623225 100644 --- a/docs/mcp/me_memory_export.md +++ b/docs/mcp/me_memory_export.md @@ -8,6 +8,7 @@ Prefer `path` to write directly to a file instead of returning content through t | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `tree` | `string \| null` | no | Tree path filter. Omit or pass `null` for all memories. | | `meta` | `object \| null` | no | Metadata filter. Omit or pass `null` to skip. | | `temporal` | `object \| null` | no | Temporal filter. Omit or pass `null` to skip. | diff --git a/docs/mcp/me_memory_get.md b/docs/mcp/me_memory_get.md index 8fa1eb97..d594970e 100644 --- a/docs/mcp/me_memory_get.md +++ b/docs/mcp/me_memory_get.md @@ -8,6 +8,7 @@ Returns the full memory including content, tree, name, meta, temporal, and embed | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `id` | `string` | yes | The UUID of the memory to retrieve. | ## Returns diff --git a/docs/mcp/me_memory_get_by_path.md b/docs/mcp/me_memory_get_by_path.md index 5ca1e32e..8be835b0 100644 --- a/docs/mcp/me_memory_get_by_path.md +++ b/docs/mcp/me_memory_get_by_path.md @@ -13,6 +13,7 @@ Use `me_memory_get` when you already have the UUID. | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `path` | `string` | yes | The `tree/name` path, e.g. `/share/auth/jwt-rotation`. | ## Returns diff --git a/docs/mcp/me_memory_import.md b/docs/mcp/me_memory_import.md index 3ff59429..5fb525a1 100644 --- a/docs/mcp/me_memory_import.md +++ b/docs/mcp/me_memory_import.md @@ -8,6 +8,7 @@ Parses the input according to the specified format and creates all memories in o | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `path` | `string \| null` | no | Absolute path to a file or directory. Directories are imported recursively. Format is inferred from extension (`.json`, `.yaml`, `.yml`, `.md`, `.ndjson`, `.jsonl`). Mutually exclusive with `content`. Omit or pass `null` if providing `content`. | | `content` | `string \| null` | no | Raw content to import (JSON array, YAML array, or Markdown with frontmatter). Mutually exclusive with `path`. Omit or pass `null` if providing `path`. | | `format` | `string \| null` | no | Content format: `"json"`, `"yaml"`, or `"md"`. Required when using `content`. Optional when using `path` (inferred from file extension). Omit or pass `null` to skip. | diff --git a/docs/mcp/me_memory_mv.md b/docs/mcp/me_memory_mv.md index 0e4c3cc3..30ebeb89 100644 --- a/docs/mcp/me_memory_mv.md +++ b/docs/mcp/me_memory_mv.md @@ -8,6 +8,7 @@ Works like `mv` in a filesystem -- all memories under the source prefix get thei | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `source` | `string` | yes | Source tree prefix to move from. Must be non-empty. | | `destination` | `string` | yes | Destination tree prefix to move to. | | `dry_run` | `boolean` | yes | If `true`, return the count without moving. If `false`, execute the move. | diff --git a/docs/mcp/me_memory_search.md b/docs/mcp/me_memory_search.md index d2a92733..3ed923e1 100644 --- a/docs/mcp/me_memory_search.md +++ b/docs/mcp/me_memory_search.md @@ -8,6 +8,7 @@ Supports three search modes: **semantic** (meaning-based), **fulltext** (keyword | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `semantic` | `string \| null` | no | Natural language query for semantic search. Omit or pass `null` to skip. | | `fulltext` | `string \| null` | no | Keywords/phrases for BM25 exact matching. Omit or pass `null` to skip. | | `grep` | `string \| null` | no | POSIX regex pattern filter on content (case-insensitive). Applied as a WHERE filter alongside other filters. Omit or pass `null` to skip. | diff --git a/docs/mcp/me_memory_tree.md b/docs/mcp/me_memory_tree.md index a2152fed..e2308371 100644 --- a/docs/mcp/me_memory_tree.md +++ b/docs/mcp/me_memory_tree.md @@ -8,6 +8,7 @@ Shows how memories are organized and how many exist at each level. Use this to u | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `tree` | `string \| null` | no | Root path to display from (e.g., `/work/projects`). Omit or pass `null` for the full tree. | | `levels` | `integer \| null` | no | Maximum depth to display. Omit or pass `null` for unlimited. | diff --git a/docs/mcp/me_memory_update.md b/docs/mcp/me_memory_update.md index 51cf65e1..d2cf837a 100644 --- a/docs/mcp/me_memory_update.md +++ b/docs/mcp/me_memory_update.md @@ -10,6 +10,7 @@ Provide the ID, the current `version_hash` from a recent get/search/create/updat | Name | Type | Required | Description | |------|------|----------|-------------| +| `space` | `string` | varies | Absent in locked mode; required nonempty string in multi-space mode. It selects the same-server space for this call. | | `id` | `string` | yes | The UUID of the memory to update. | | `version_hash` | `string` | yes | The current `versionHash` of the memory (32-char md5 hex). Get it from the most recent `me_memory_get` / `me_memory_search` / `me_memory_create` / `me_memory_update` response. A stale or incorrect value fails with `CONFLICT` and does not modify the memory. | | `content` | `string \| null` | no | New content. Omit or pass `null` to keep existing. | diff --git a/docs/mcp/me_space_list.md b/docs/mcp/me_space_list.md new file mode 100644 index 00000000..d30191de --- /dev/null +++ b/docs/mcp/me_space_list.md @@ -0,0 +1,35 @@ +# me_space_list + +List spaces available to the credential used by this MCP server. + +This tool is available in **multi-space** mode. It is absent when the server is +locked with `--space` or `ME_SPACE`. + +## Parameters + +This tool takes no parameters. + +## Returns + +```json +{ + "spaces": [ + { + "slug": "6nnv8r3gz9jr", + "name": "Acme" + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `slug` | `string` | The space slug to pass as `space` to a memory tool. | +| `name` | `string` | The space display name. | + +## Notes + +- The list is filtered by the credential's authorization. Restricted API keys + return only their declared spaces. +- Supplying a returned slug to a memory tool selects that space for that call; + it does not grant access. diff --git a/packages/cli/commands/claude.ts b/packages/cli/commands/claude.ts index 3c857550..adf844a6 100644 --- a/packages/cli/commands/claude.ts +++ b/packages/cli/commands/claude.ts @@ -110,7 +110,7 @@ function createClaudeInstallCommand(): Command { .option("--server ", "server URL to embed in the config") .option( "--space ", - "pin a space (default: resolve ME_SPACE / active space at runtime)", + "pin a space (otherwise MCP is multi-space unless ME_SPACE is set)", ) .option( "--dev", diff --git a/packages/cli/commands/codex.ts b/packages/cli/commands/codex.ts index 6755bb1a..36f8c203 100644 --- a/packages/cli/commands/codex.ts +++ b/packages/cli/commands/codex.ts @@ -64,7 +64,7 @@ function createCodexInstallCommand(): Command { .option("--server ", "server URL to embed in MCP config") .option( "--space ", - "pin a space (default: resolve ME_SPACE / active space at runtime)", + "pin a space (otherwise MCP is multi-space unless ME_SPACE is set)", ) .action(async (opts: AgentInstallOptions, cmd: Command) => { const globalOpts = cmd.optsWithGlobals(); diff --git a/packages/cli/commands/gemini.ts b/packages/cli/commands/gemini.ts index bf9c297d..ef5bb073 100644 --- a/packages/cli/commands/gemini.ts +++ b/packages/cli/commands/gemini.ts @@ -67,7 +67,7 @@ function createGeminiInstallCommand(): Command { .option("--server ", "server URL to embed in MCP config") .option( "--space ", - "pin a space (default: resolve ME_SPACE / active space at runtime)", + "pin a space (otherwise MCP is multi-space unless ME_SPACE is set)", ) .option( "-s, --scope ", diff --git a/packages/cli/commands/mcp.test.ts b/packages/cli/commands/mcp.test.ts index 779f36bb..377e5bff 100644 --- a/packages/cli/commands/mcp.test.ts +++ b/packages/cli/commands/mcp.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { blankFlag, isLegacyApiKey } from "./mcp.ts"; +import { Command } from "commander"; +import type { McpServerOptions } from "../mcp/server.ts"; +import { + blankFlag, + createMcpRunAction, + isLegacyApiKey, + resolveMcpSpace, +} from "./mcp.ts"; // blankFlag normalizes the plugin's `--server/--api-key/--space ${user_config.X}` // args: blank (or an unsubstituted placeholder) → undefined, so resolution falls @@ -9,11 +16,20 @@ describe("blankFlag", () => { expect(blankFlag("")).toBeUndefined(); }); + test("whitespace-only string → undefined (falls back)", () => { + expect(blankFlag(" ")).toBeUndefined(); + expect(blankFlag(" \t\n")).toBeUndefined(); + }); + test("unsubstituted ${...} placeholder → undefined (falls back)", () => { expect(blankFlag("${user_config.server}")).toBeUndefined(); expect(blankFlag("${user_config.api_key}")).toBeUndefined(); }); + test("whitespace-padded ${...} placeholder → undefined (falls back)", () => { + expect(blankFlag(" ${user_config.space} ")).toBeUndefined(); + }); + test("undefined / non-string → undefined", () => { expect(blankFlag(undefined)).toBeUndefined(); expect(blankFlag(123)).toBeUndefined(); @@ -25,6 +41,13 @@ describe("blankFlag", () => { ); expect(blankFlag("7plcwreyoxdd")).toBe("7plcwreyoxdd"); }); + + test("a whitespace-padded real value is trimmed", () => { + expect(blankFlag(" 7plcwreyoxdd ")).toBe("7plcwreyoxdd"); + expect(blankFlag("\thttps://api.memory.build\n")).toBe( + "https://api.memory.build", + ); + }); }); // Guards the CLI's copy of the legacy-key detector (duplicated from @@ -51,3 +74,91 @@ describe("isLegacyApiKey", () => { ).toBe(false); }); }); + +describe("resolveMcpSpace", () => { + test("a --space flag locks the MCP tool surface", () => { + expect(resolveMcpSpace("flagspace001", undefined)).toEqual({ + lockedSpace: "flagspace001", + spaceMode: "locked", + }); + }); + + test("ME_SPACE locks the MCP tool surface", () => { + expect(resolveMcpSpace(undefined, "envspace0001")).toEqual({ + lockedSpace: "envspace0001", + spaceMode: "locked", + }); + }); + + test("a blank plugin flag starts multi-space mode", () => { + expect(resolveMcpSpace("${user_config.space}", undefined)).toEqual({ + spaceMode: "multi", + }); + }); + + test("no explicit space starts multi-space mode", () => { + expect(resolveMcpSpace(undefined, undefined)).toEqual({ + spaceMode: "multi", + }); + }); + + test("an empty ME_SPACE starts multi-space mode", () => { + expect(resolveMcpSpace(undefined, "")).toEqual({ + spaceMode: "multi", + }); + }); + + test("a whitespace-only ME_SPACE starts multi-space mode", () => { + expect(resolveMcpSpace(undefined, " ")).toEqual({ + spaceMode: "multi", + }); + }); + + test("a --space flag wins over ME_SPACE when both are set", () => { + expect(resolveMcpSpace("flagspace001", "envspace0001")).toEqual({ + lockedSpace: "flagspace001", + spaceMode: "locked", + }); + }); + + test("a blank --space flag falls back to ME_SPACE", () => { + expect(resolveMcpSpace("${user_config.space}", "envspace0001")).toEqual({ + lockedSpace: "envspace0001", + spaceMode: "locked", + }); + }); +}); + +test("MCP startup passes multi-space mode instead of an active-space default", async () => { + const previousSpace = process.env.ME_SPACE; + delete process.env.ME_SPACE; + try { + let received: McpServerOptions | undefined; + const action = createMcpRunAction({ + resolveCredentials: () => ({ + server: "https://api.example.com", + loggedIn: true, + activeSpace: "ignoredspace", + captureEnabled: false, + }), + memoryBearer: () => ({ + getToken: async () => "token", + onUnauthorized: async () => undefined, + }), + runMcpServer: async (options) => { + received = options; + }, + }); + const command = new Command(); + command.parse(["node", "test"]); + + await action({}, command); + expect(received).toMatchObject({ + server: "https://api.example.com", + spaceMode: "multi", + }); + } finally { + if (previousSpace === undefined) delete process.env.ME_SPACE; + else process.env.ME_SPACE = previousSpace; + } +}); diff --git a/packages/cli/commands/mcp.ts b/packages/cli/commands/mcp.ts index 9d05bcc9..793f2943 100644 --- a/packages/cli/commands/mcp.ts +++ b/packages/cli/commands/mcp.ts @@ -2,21 +2,23 @@ * me mcp — run the MCP server over stdio. * * Authenticates to a space with either a human session (from `me login`) or an - * API key, and targets the active space (the X-Me-Space). Resolution: + * API key. Resolution: * - token: --api-key > ME_API_KEY > stored session token - * - space: --space > ME_SPACE > stored active space + * - space: --space > ME_SPACE (locked), otherwise multi-space * - * The common case is a logged-in human: `me mcp` just works against the active - * space. API-key users must supply a space via - * --space / ME_SPACE — the installers bake it in). + * An unpinned MCP server is always multi-space and tools require a space + * argument. API-key installers can also run multi-space; --space stays opt-in. * * MCP registration with individual AI tools lives in per-agent commands: * me opencode install, me gemini install, me codex install * Claude Code uses the Memory Engine plugin instead of a CLI installer. */ import { Command } from "commander"; -import { resolveCredentials } from "../credentials.ts"; -import { runMcpServer } from "../mcp/server.ts"; +import { + type ResolvedCredentials, + resolveCredentials, +} from "../credentials.ts"; +import { type McpSpaceSelection, runMcpServer } from "../mcp/server.ts"; import { memoryBearer } from "../session.ts"; /** @@ -37,26 +39,57 @@ export function isLegacyApiKey(token: string): boolean { } /** - * Treat unset / empty / unsubstituted-placeholder flag values as missing. The - * Claude Code plugin's .mcp.json passes `--server/--api-key/--space - * ${user_config.X}` statically; when left blank each arrives as `""` (or the - * literal `${...}` placeholder), which must fall through to the live `me` config - * (server/session/active space), not be used verbatim. + * Treat unset / empty / whitespace-only / unsubstituted-placeholder flag values + * as missing. The Claude Code plugin's .mcp.json passes `--server/--api-key/ + * --space ${user_config.X}` statically; when left blank each arrives as `""` + * (or the literal `${...}` placeholder), which must fall through to the live + * `me` server/session config, not be used verbatim. Whitespace-only strings — + * e.g. `ME_SPACE=" "` — are treated as blank, and any legitimate value is + * trimmed so an accidental leading/trailing space cannot corrupt the wire. */ export function blankFlag(v: unknown): string | undefined { - if (typeof v !== "string" || v === "" || /^\$\{.*\}$/.test(v)) - return undefined; - return v; + if (typeof v !== "string") return undefined; + const trimmed = v.trim(); + if (trimmed === "" || /^\$\{.*\}$/.test(trimmed)) return undefined; + return trimmed; } -function createMcpRunAction() { +/** + * Resolve the MCP mode. Explicit process configuration is a capability-surface + * choice: a flag or ME_SPACE locks the schemas; otherwise every memory tool + * requires a per-call space. + */ +export function resolveMcpSpace( + flagValue: unknown, + envSpace: string | undefined, +): McpSpaceSelection { + const explicitSpace = blankFlag(flagValue) ?? blankFlag(envSpace); + if (explicitSpace) { + return { spaceMode: "locked", lockedSpace: explicitSpace }; + } + return { spaceMode: "multi" }; +} + +interface McpRunActionDependencies { + resolveCredentials: (serverFlag?: string) => ResolvedCredentials; + memoryBearer: typeof memoryBearer; + runMcpServer: typeof runMcpServer; +} + +export function createMcpRunAction( + dependencies: McpRunActionDependencies = { + resolveCredentials, + memoryBearer, + runMcpServer, + }, +) { return async (_opts: Record, cmd: Command) => { const opts = cmd.optsWithGlobals(); // Run server through blankFlag like api_key/space below: the plugin's // .mcp.json always passes `--server ${user_config.server}`, which arrives as // "" (or the literal placeholder) when left blank — it must fall back to the // live `me` config (ME_SERVER / default_server), not be used verbatim. - const creds = resolveCredentials(blankFlag(opts.server)); + const creds = dependencies.resolveCredentials(blankFlag(opts.server)); // Bearer: --api-key > ME_API_KEY (creds.apiKey), else the logged-in human's // OAuth session (resolved + refreshed at runtime by `memoryBearer`). @@ -78,19 +111,12 @@ function createMcpRunAction() { process.exit(1); } - // Space: --space > ME_SPACE / stored active space. - const space = blankFlag(opts.space) ?? creds.activeSpace; - if (!space) { - console.error( - "Error: no active space. Run 'me space use ', or pass --space / set ME_SPACE.", - ); - process.exit(1); - } + const space = resolveMcpSpace(opts.space, process.env.ME_SPACE); - await runMcpServer({ + await dependencies.runMcpServer({ server: creds.server, - bearer: memoryBearer(creds.server, apiKey), - space, + bearer: dependencies.memoryBearer(creds.server, apiKey), + ...space, }); }; } @@ -101,7 +127,7 @@ export function createMcpCommand(): Command { .option("--api-key ", "API key (else uses the stored session)") .option( "--space ", - "active space (else ME_SPACE / stored active space)", + "lock MCP to this space (else memory tools require a per-call space)", ) .action(createMcpRunAction()); } diff --git a/packages/cli/commands/opencode.ts b/packages/cli/commands/opencode.ts index 7e56205f..bda4d330 100644 --- a/packages/cli/commands/opencode.ts +++ b/packages/cli/commands/opencode.ts @@ -205,7 +205,7 @@ function createOpenCodeInstallCommand(): Command { .option("--server ", "server URL to embed in MCP config") .option( "--space ", - "pin a space (default: resolve ME_SPACE / active space at runtime)", + "pin a space (otherwise MCP is multi-space unless ME_SPACE is set)", ) .option( "--scope ", diff --git a/packages/cli/mcp/agent-install.test.ts b/packages/cli/mcp/agent-install.test.ts new file mode 100644 index 00000000..98b5daf1 --- /dev/null +++ b/packages/cli/mcp/agent-install.test.ts @@ -0,0 +1,39 @@ +/** + * Unit tests for `agent-install.ts` helpers. + * + * `runAgentMcpInstall` itself shells out to external binaries (`claude mcp + * add`, etc.) and calls `process.exit`, so we test the decisions it makes + * through the pure `multiSpaceWarning` helper it delegates to. Both the + * session and the api-key branch consult the same helper — the wire-level + * runtime behavior is identical (the MCP server starts multi-space unless + * `--space` was baked in), and the warning tracks that. + */ + +import { describe, expect, test } from "bun:test"; +import { multiSpaceWarning } from "./agent-install.ts"; + +describe("multiSpaceWarning", () => { + test("returns a warn message when no space is baked in", () => { + const warn = multiSpaceWarning(undefined); + expect(warn).toBeString(); + // Guide the operator to the discovery flow. + expect(warn).toContain("multi-space"); + expect(warn).toContain("me_space_list"); + expect(warn).toContain("--space"); + }); + + test("stays quiet when a space is baked in (locked at runtime)", () => { + expect(multiSpaceWarning("abc123def456")).toBeUndefined(); + }); + + test("warns when a whitespace-only flag becomes multi-space at runtime", () => { + expect(multiSpaceWarning(" ")).toBeString(); + }); + + test("fires on the api-key path too — the warning is credential-agnostic", () => { + // The install code emits this after picking meCmd for either the session + // OR the api-key path, so a headless install without --space is now + // warned the same way as a session install without --space. + expect(multiSpaceWarning(undefined)).toBeString(); + }); +}); diff --git a/packages/cli/mcp/agent-install.ts b/packages/cli/mcp/agent-install.ts index c5befedb..dd12c6b0 100644 --- a/packages/cli/mcp/agent-install.ts +++ b/packages/cli/mcp/agent-install.ts @@ -22,6 +22,20 @@ export interface AgentInstallOptions { projectDir?: string; } +/** + * When an install bakes no `--space`, the runtime MCP server starts in + * multi-space mode and agents must call `me_space_list` + pass `space` on + * every memory tool. Return the warn string in that case (both session and + * api-key installs behave the same at runtime), or undefined when a space + * pin will lock the server. Exposed pure for testability. + */ +export function multiSpaceWarning( + space: string | undefined, +): string | undefined { + if (space?.trim()) return undefined; + return "No MCP space pinned — the server will start in multi-space mode. Agents must call me_space_list, then pass space to every memory tool. Re-run with --space to pin one."; +} + /** * Run MCP-only install for a single agent tool. * @@ -49,22 +63,12 @@ export async function runAgentMcpInstall( process.exit(1); } - // Default path: no api key → the MCP server uses your login SESSION, resolved - // from the keychain/config at runtime each time it starts (so it survives - // `me login`). Pass --api-key / ME_API_KEY only for headless/CI use where the - // process can't reach your keychain; that bakes a long-lived global key and - // must pin a space. The `--space` flag pins the space either way; otherwise - // the session path resolves it at runtime from ME_SPACE / active space. + // The MCP server resolves a login session from the keychain/config at runtime, + // so a personal install survives `me login`. API keys are also global and may + // run multi-space. Only an explicit --space pins either credential type. let meCmd: string[]; if (apiKey) { - const space = opts.space ?? creds.activeSpace; - if (!space) { - clack.log.error( - "No space for the API key. Pass --space, set ME_SPACE, or run 'me space use ' (keys are global, so the space must be fixed).", - ); - process.exit(1); - } - meCmd = buildMeCommand({ server, apiKey, space }); + meCmd = buildMeCommand({ server, apiKey, space: opts.space }); } else { if (!creds.loggedIn) { clack.log.error( @@ -73,15 +77,15 @@ export async function runAgentMcpInstall( process.exit(1); } // Bake only --server (+ an explicit --space pin if given); the session token - // and space resolve at runtime. + // resolves at runtime and an unpinned server is multi-space. meCmd = buildMeCommand({ server, space: opts.space }); - if (!opts.space && !creds.activeSpace) { - clack.log.warn( - "No active space set — the MCP server will fail until you run 'me space use ' (or set ME_SPACE). Re-run with --space to pin one.", - ); - } } + // Emit the multi-space notice on both credential paths: an unpinned MCP is + // multi-space regardless of whether it uses a session or an api key. + const warning = multiSpaceWarning(opts.space); + if (warning) clack.log.warn(warning); + // For CLI tools, require the binary to be on PATH. JSON-file tools // (e.g. OpenCode) just edit a config file and don't need the binary. if (tool.method === "cli" && Bun.which(tool.bin) === null) { diff --git a/packages/cli/mcp/install.test.ts b/packages/cli/mcp/install.test.ts index 3eebe83b..9a58bf17 100644 --- a/packages/cli/mcp/install.test.ts +++ b/packages/cli/mcp/install.test.ts @@ -18,13 +18,29 @@ describe("buildMeCommand", () => { expect(cmd[1]).toBe("mcp"); }); - test("session default bakes only --server (token + space resolve at runtime)", () => { + test("unpinned MCP command bakes only --server", () => { const cmd = buildMeCommand({ server: "https://example.com" }); expect(cmd).toEqual(["me", "mcp", "--server", "https://example.com"]); expect(cmd).not.toContain("--api-key"); expect(cmd).not.toContain("--space"); }); + test("headless MCP command can remain multi-space", () => { + const cmd = buildMeCommand({ + server: "https://example.com", + apiKey: "k", + }); + expect(cmd).toEqual([ + "me", + "mcp", + "--server", + "https://example.com", + "--api-key", + "k", + ]); + expect(cmd).not.toContain("--space"); + }); + test("pins --space when given (session path with explicit space)", () => { const cmd = buildMeCommand({ server: "https://example.com", diff --git a/packages/cli/mcp/install.ts b/packages/cli/mcp/install.ts index bf08ff5f..8929cff5 100644 --- a/packages/cli/mcp/install.ts +++ b/packages/cli/mcp/install.ts @@ -127,10 +127,10 @@ export function detectInstalledTools(): McpTool[] { * when provided: * - **Default (no api key):** the MCP server resolves your login *session* from * the keychain/config at runtime (so it keeps working across `me login`), and - * the space from `ME_SPACE`/active space at runtime — nothing secret or - * stateful is written into the config. - * - **Headless agent (`--api-key`):** the global key is baked in, along with a - * pinned `--space` (keys aren't space-bound, so the space must be fixed). + * no space is selected until a tool call supplies one (unless ME_SPACE is + * present at runtime) — nothing secret or stateful is written into config. + * - **Headless agent (`--api-key`):** the global key is baked in; it can be + * multi-space or explicitly pinned with `--space`. * * Always uses bare `me` — the binary is expected to be on PATH whether installed * via the install script, Homebrew, or npm. diff --git a/packages/cli/mcp/server.test.ts b/packages/cli/mcp/server.test.ts new file mode 100644 index 00000000..95f18c21 --- /dev/null +++ b/packages/cli/mcp/server.test.ts @@ -0,0 +1,423 @@ +import { afterEach, expect, test } from "bun:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { + createMcpServer, + type McpServerOptions, + type McpSpaceMode, +} from "./server.ts"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +type RequestCapture = { + method: string; + headers: Record; +}; + +function assertMcpServerOptions(_options: McpServerOptions): void {} + +test("McpServerOptions ties locked space to locked mode", () => { + assertMcpServerOptions({ + server: "https://api.example.com", + bearer: { + getToken: async () => "token", + onUnauthorized: async () => undefined, + }, + spaceMode: "locked", + lockedSpace: "lockedspace1", + }); + assertMcpServerOptions({ + server: "https://api.example.com", + bearer: { + getToken: async () => "token", + onUnauthorized: async () => undefined, + }, + spaceMode: "multi", + }); + + // @ts-expect-error locked MCP must have a space to lock. + assertMcpServerOptions({ + server: "https://api.example.com", + bearer: { + getToken: async () => "token", + onUnauthorized: async () => undefined, + }, + spaceMode: "locked", + }); + // @ts-expect-error multi-space MCP cannot carry a locked space. + assertMcpServerOptions({ + server: "https://api.example.com", + bearer: { + getToken: async () => "token", + onUnauthorized: async () => undefined, + }, + spaceMode: "multi", + lockedSpace: "should-not-be-set", + }); +}); + +function captureFetch( + spaces = [ + { slug: "defaultspace", name: "Default" }, + { slug: "otherspace01", name: "Other" }, + ], +): RequestCapture[] { + const requests: RequestCapture[] = []; + globalThis.fetch = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + const body = JSON.parse(String(init?.body)) as { method: string }; + requests.push({ + method: body.method, + headers: (init?.headers as Record | undefined) ?? {}, + }); + + const result = + body.method === "space.list" + ? { + spaces, + } + : body.method === "access.effective" + ? { principal: { kind: "u" }, access: [] } + : { nodes: [] }; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + return requests; +} + +async function connect(spaceMode: McpSpaceMode, lockedSpace?: string) { + const space = + spaceMode === "multi" + ? { spaceMode } + : { spaceMode, lockedSpace: lockedSpace ?? "defaultspace" }; + const server = createMcpServer({ + server: "https://api.example.com", + bearer: { + getToken: async () => "token", + onUnauthorized: async () => undefined, + }, + ...space, + }); + const client = new Client({ name: "test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + return { client, server }; +} + +function memoryTools(tools: Awaited>) { + return tools.tools.filter((tool) => tool.name.startsWith("me_memory_")); +} + +test("multi-space MCP servers require space selection and expose discovery", async () => { + const { client, server } = await connect("multi"); + try { + const tools = await client.listTools(); + expect(tools.tools.some((tool) => tool.name === "me_space_list")).toBe( + true, + ); + expect(memoryTools(tools)).toHaveLength(15); + for (const tool of memoryTools(tools)) { + expect(tool.inputSchema.properties).toHaveProperty("space"); + expect(tool.inputSchema.required).toContain("space"); + } + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("locked MCP servers hide space selection and discovery", async () => { + const { client, server } = await connect("locked"); + try { + const tools = await client.listTools(); + expect(tools.tools.some((tool) => tool.name === "me_space_list")).toBe( + false, + ); + expect(memoryTools(tools)).toHaveLength(15); + for (const tool of memoryTools(tools)) { + expect(tool.inputSchema.properties ?? {}).not.toHaveProperty("space"); + } + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("each tool call uses its selected space without header leakage", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + await Promise.all([ + client.callTool({ + name: "me_memory_tree", + arguments: { space: "otherspace01" }, + }), + client.callTool({ + name: "me_memory_tree", + arguments: { space: "defaultspace" }, + }), + ]); + + const treeRequests = requests.filter( + (request) => request.method === "memory.tree", + ); + expect( + treeRequests.map((request) => request.headers["X-Me-Space"]).sort(), + ).toEqual(["defaultspace", "otherspace01"]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("memory context reports the space selected for its call", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + const result = await client.callTool({ + name: "me_memory_context", + arguments: { space: "otherspace01" }, + }); + const content = ( + result.content as Array<{ type: string; text: string }> | undefined + )?.[0]; + if (!content || content.type !== "text") throw new Error("missing text"); + const context = JSON.parse(content.text) as Record; + expect(context).not.toHaveProperty("activeSpace"); + expect(context).not.toHaveProperty("selectedSpace"); + expect(requests).toEqual([ + { + method: "access.effective", + headers: expect.objectContaining({ "X-Me-Space": "otherspace01" }), + }, + ]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("locked memory context uses the configured space", async () => { + const requests = captureFetch(); + const { client, server } = await connect("locked", "lockedspace01"); + try { + const result = await client.callTool({ + name: "me_memory_context", + arguments: {}, + }); + const content = ( + result.content as Array<{ type: string; text: string }> | undefined + )?.[0]; + if (!content || content.type !== "text") throw new Error("missing text"); + expect(JSON.parse(content.text)).toMatchObject({ + activeSpace: "lockedspace01", + }); + expect(requests[0]?.headers).toEqual( + expect.objectContaining({ "X-Me-Space": "lockedspace01" }), + ); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("space discovery uses the account endpoint without a space header", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + const result = await client.callTool({ + name: "me_space_list", + arguments: {}, + }); + expect(result.content).toEqual([ + { + type: "text", + text: JSON.stringify( + { + spaces: [ + { slug: "defaultspace", name: "Default" }, + { slug: "otherspace01", name: "Other" }, + ], + }, + null, + 2, + ), + }, + ]); + expect(requests).toEqual([ + { + method: "space.list", + headers: expect.not.objectContaining({ + "X-Me-Space": expect.anything(), + }), + }, + ]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("multi-space memory tools reject missing space before making a request", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + const missing = await client.callTool({ + name: "me_memory_tree", + arguments: {}, + }); + expect(missing.isError).toBe(true); + expect(requests).toEqual([]); + + await client.callTool({ + name: "me_memory_tree", + arguments: { space: "otherspace01" }, + }); + expect(requests).toEqual([ + { + method: "memory.tree", + headers: expect.objectContaining({ "X-Me-Space": "otherspace01" }), + }, + ]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("multi-space memory context rejects missing space before making a request", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + const result = await client.callTool({ + name: "me_memory_context", + arguments: {}, + }); + expect(result.isError).toBe(true); + expect(requests).toEqual([]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("multi-space memory tools reject an empty space before making a request", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + const result = await client.callTool({ + name: "me_memory_tree", + arguments: { space: "" }, + }); + expect(result.isError).toBe(true); + expect(requests).toEqual([]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("multi-space memory tools reject a null space before making a request", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + // multiSpaceInput is z.string().min(1) — no .nullable() — so a client that + // sends {space: null} must fail schema validation before any HTTP call. + const result = await client.callTool({ + name: "me_memory_tree", + arguments: { space: null }, + }); + expect(result.isError).toBe(true); + expect(requests).toEqual([]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("locked memory tools ignore a smuggled space argument", async () => { + const requests = captureFetch(); + // Locked-mode schemas omit `space`. The MCP SDK's zod object parser strips + // unknown keys by default, so a client that still sends {space: "other"} + // must land at X-Me-Space: , never at the smuggled slug. + const { client, server } = await connect("locked", "lockedspace01"); + try { + await client.callTool({ + name: "me_memory_tree", + arguments: { space: "attackerspace" }, + }); + const treeRequests = requests.filter( + (request) => request.method === "memory.tree", + ); + expect(treeRequests).toHaveLength(1); + expect(treeRequests[0]?.headers["X-Me-Space"]).toBe("lockedspace01"); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("space discovery preserves the restricted credential's result", async () => { + const requests = captureFetch([ + { slug: "allowedspace", name: "Allowed Space" }, + ]); + const { client, server } = await connect("multi"); + try { + const result = await client.callTool({ + name: "me_space_list", + arguments: {}, + }); + expect(result.content).toEqual([ + { + type: "text", + text: JSON.stringify( + { + spaces: [{ slug: "allowedspace", name: "Allowed Space" }], + }, + null, + 2, + ), + }, + ]); + expect(requests).toHaveLength(1); + expect(requests[0]?.headers).toEqual( + expect.objectContaining({ Authorization: "Bearer token" }), + ); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); + +test("concurrent writes retain their selected spaces", async () => { + const requests = captureFetch(); + const { client, server } = await connect("multi"); + try { + await Promise.all([ + client.callTool({ + name: "me_memory_create", + arguments: { + content: "first", + tree: "/share", + space: "defaultspace", + }, + }), + client.callTool({ + name: "me_memory_create", + arguments: { + content: "second", + tree: "/share", + space: "otherspace01", + }, + }), + ]); + const createRequests = requests.filter( + (request) => request.method === "memory.create", + ); + expect( + createRequests.map((request) => request.headers["X-Me-Space"]).sort(), + ).toEqual(["defaultspace", "otherspace01"]); + } finally { + await Promise.all([client.close(), server.close()]); + } +}); diff --git a/packages/cli/mcp/server.ts b/packages/cli/mcp/server.ts index 0d16bac5..4de96f38 100644 --- a/packages/cli/mcp/server.ts +++ b/packages/cli/mcp/server.ts @@ -15,8 +15,8 @@ import { stringify as yamlStringify } from "yaml"; import { z } from "zod"; import { CLIENT_VERSION } from "../../../version"; import { batchCreateChunked } from "../chunk.ts"; -import type { MemoryClient } from "../client.ts"; -import { createMemoryClient } from "../client.ts"; +import type { MemoryClient, UserClient } from "../client.ts"; +import { createMemoryClient, createUserClient } from "../client.ts"; import { formatMemoryAsMarkdown, uniqueExportFilename, @@ -50,6 +50,7 @@ Use it proactively: - Before nontrivial work, search for prior decisions, project context, and conventions with me_memory_search. - Choose the search mode deliberately: semantic for meaning/concepts, fulltext for exact words/identifiers, or both when you need both kinds of match. - Use me_memory_context when you need to confirm which space/principal you are acting as, what paths you can read/write/own, or why a search/create may be empty or forbidden. +- When a memory tool exposes a space parameter, it selects that same-server space for only that call; it never grants access. Use me_space_list to discover selectable spaces, and supply space when it is required. - Use me_memory_tree and tree filters to understand what is visible, but remember that access is grant-based: missing access can look like empty search results or not found. - Store durable facts, decisions, conventions, runbooks, and workarounds with me_memory_create when they will help future work. - Choose the tree from project/user instructions or visible context. Do not assume every space uses the same tree layout or that shared paths are writable. @@ -64,16 +65,86 @@ Detailed agent instructions: ${DOCS_BASE}/mcp/agent-instructions.md`; // Tool Registration // ============================================================================= -interface McpRuntimeContext { - server: string; - space: string; +type McpRuntimeContext = McpSpaceSelection & { server: string }; + +const multiSpaceInput = z + .string() + .min(1) + .describe( + "The same-server space slug this call operates in, as returned by me_space_list. Selecting a space never grants access; the server still enforces membership and tree grants.", + ); + +function inputSchema>( + fields: T, + runtime: McpRuntimeContext, +): T & { space?: typeof multiSpaceInput } { + // The concrete startup-mode branch controls the protocol schema. Handlers + // resolve the selected space through spaceFor instead of reading args.space. + if (runtime.spaceMode === "locked") { + return fields; + } + return { ...fields, space: multiSpaceInput }; } function registerTools( server: McpServer, - client: MemoryClient, + getMemoryClient: (space: string) => MemoryClient, + getUserClient: () => UserClient, runtime: McpRuntimeContext, ): void { + function spaceFor(args: object): string { + if (runtime.spaceMode === "locked") return runtime.lockedSpace; + if ("space" in args && typeof args.space === "string") return args.space; + throw new Error( + "Invariant: reached a multi-space memory call without a space.", + ); + } + + function clientFor(args: object): MemoryClient { + return getMemoryClient(spaceFor(args)); + } + + if (runtime.spaceMode === "multi") { + server.registerTool( + "me_space_list", + { + title: "List Spaces", + description: `List spaces available to the credential used by this MCP server. + +Use a returned slug in a memory tool's space parameter to select that same-server space for one call. + +Docs: ${docUrl("me_space_list")}`, + inputSchema: {}, + annotations: { + title: "List Spaces", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + const result = await getUserClient().space.list(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + spaces: result.spaces.map((space) => ({ + slug: space.slug, + name: space.name, + })), + }, + null, + 2, + ), + }, + ], + }; + }, + ); + } + // me_memory_context server.registerTool( "me_memory_context", @@ -86,7 +157,7 @@ Returns the active server and space, presented principal, and effective tree acc Call this before choosing where to store memories or when diagnosing empty search results and permission failures. Docs: ${docUrl("me_memory_context")}`, - inputSchema: {}, + inputSchema: inputSchema({}, runtime), annotations: { title: "Memory Context", readOnlyHint: true, @@ -94,8 +165,9 @@ Docs: ${docUrl("me_memory_context")}`, idempotentHint: true, }, }, - async () => { - const result = await client.access.effective({}); + async (args) => { + const space = spaceFor(args); + const result = await getMemoryClient(space).access.effective({}); const mode = result.principal.kind === "s" ? "service-account" : "user"; return { content: [ @@ -104,7 +176,9 @@ Docs: ${docUrl("me_memory_context")}`, text: JSON.stringify( { server: runtime.server, - activeSpace: runtime.space, + ...(runtime.spaceMode === "locked" + ? { activeSpace: space } + : {}), mode, ...result, }, @@ -125,54 +199,59 @@ Docs: ${docUrl("me_memory_context")}`, description: `Store a new memory. Docs: ${docUrl("me_memory_create")}`, - inputSchema: { - id: z - .string() - .optional() - .nullable() - .describe( - "UUIDv7 for idempotent creates (omit or null to auto-generate)", - ), - content: z.string().min(1).describe("The content of the memory"), - meta: z - .record(z.string(), z.any()) - .optional() - .nullable() - .describe("Key-value metadata pairs"), - tree: z - .string() - .describe( - "Hierarchical path where the memory is stored (required). Choose deliberately from project/user instructions, prior memories, visible tree structure, and your write access; do not assume every space uses the same shared/private layout.", - ), - name: z - .string() - .optional() - .nullable() - .describe( - 'Optional filename-like leaf name, unique within the tree (e.g. "jwt-rotation", "config.yaml"). Lets you address the memory later as `tree/name` and dedupe re-tells.', - ), - temporal: z - .object({ - start: z.string().describe("ISO timestamp for start of time range"), - end: z - .string() - .optional() - .nullable() - .describe( - "ISO timestamp for end (omit or null for point-in-time)", - ), - }) - .optional() - .nullable() - .describe("Time range for the memory"), - on_conflict: z - .enum(["error", "replace", "ignore"]) - .optional() - .nullable() - .describe( - "On a conflict on the idempotency key (a named memory's tree+name, which takes precedence over id; else the id): 'error' (default) fails, 'replace' overwrites it in place, 'ignore' keeps the existing one.", - ), - }, + inputSchema: inputSchema( + { + id: z + .string() + .optional() + .nullable() + .describe( + "UUIDv7 for idempotent creates (omit or null to auto-generate)", + ), + content: z.string().min(1).describe("The content of the memory"), + meta: z + .record(z.string(), z.any()) + .optional() + .nullable() + .describe("Key-value metadata pairs"), + tree: z + .string() + .describe( + "Hierarchical path where the memory is stored (required). Choose deliberately from project/user instructions, prior memories, visible tree structure, and your write access; do not assume every space uses the same shared/private layout.", + ), + name: z + .string() + .optional() + .nullable() + .describe( + 'Optional filename-like leaf name, unique within the tree (e.g. "jwt-rotation", "config.yaml"). Lets you address the memory later as `tree/name` and dedupe re-tells.', + ), + temporal: z + .object({ + start: z + .string() + .describe("ISO timestamp for start of time range"), + end: z + .string() + .optional() + .nullable() + .describe( + "ISO timestamp for end (omit or null for point-in-time)", + ), + }) + .optional() + .nullable() + .describe("Time range for the memory"), + on_conflict: z + .enum(["error", "replace", "ignore"]) + .optional() + .nullable() + .describe( + "On a conflict on the idempotency key (a named memory's tree+name, which takes precedence over id; else the id): 'error' (default) fails, 'replace' overwrites it in place, 'ignore' keeps the existing one.", + ), + }, + runtime, + ), annotations: { title: "Create Memory", readOnlyHint: false, @@ -181,7 +260,7 @@ Docs: ${docUrl("me_memory_create")}`, }, }, async (args) => { - const result = await client.memory.create({ + const result = await clientFor(args).memory.create({ id: args.id ?? undefined, content: args.content, meta: args.meta ?? undefined, @@ -213,112 +292,115 @@ Docs: ${docUrl("me_memory_create")}`, Search modes: semantic (meaning), fulltext (keywords/exact text), or both (hybrid). Choose deliberately: semantic for concepts, fulltext for identifiers/errors/literal text, or both when both kinds of matching are useful. Combine with tree, meta, and temporal filters. Results scored 0-1. Docs: ${docUrl("me_memory_search")}`, - inputSchema: { - semantic: z - .string() - .optional() - .nullable() - .describe( - "Natural language query for semantic/meaning search. Use fulltext instead for exact words, identifiers, filenames, errors, or other literal text.", - ), - fulltext: z - .string() - .optional() - .nullable() - .describe("Keywords/phrases for BM25 exact matching"), - grep: z - .string() - .optional() - .nullable() - .describe( - "Regex pattern filter on content (POSIX, case-insensitive). Applied as WHERE filter alongside other filters.", - ), - meta: z - .record(z.string(), z.any()) - .optional() - .nullable() - .describe("Filter by metadata attributes"), - tree: z - .string() - .optional() - .nullable() - .describe( - "Filter by tree path. Bare path (work.projects) matches exactly \u2014 use work.projects.* to include descendants. Supports lquery patterns (*.api.*) and ltxtquery label search (api & v2).", - ), - temporal: z - .object({ - contains: z - .string() - .optional() - .nullable() - .describe("Find memories containing this point in time"), - overlaps: z - .object({ - start: z.string().describe("Start of range"), - end: z.string().describe("End of range"), - }) - .optional() - .nullable() - .describe("Find memories overlapping this range"), - within: z - .object({ - start: z.string().describe("Start of range"), - end: z.string().describe("End of range"), - }) - .optional() - .nullable() - .describe("Find memories fully within this range"), - }) - .optional() - .nullable() - .describe("Temporal filter for search"), - weights: z - .object({ - fulltext: z - .number() - .optional() - .nullable() - .describe("Weight for BM25 keyword matching (0-1)"), - semantic: z - .number() - .optional() - .nullable() - .describe("Weight for semantic similarity (0-1)"), - }) - .optional() - .nullable() - .describe("Weights for hybrid search ranking"), - candidateLimit: z - .number() - .int() - .optional() - .nullable() - .describe( - "Candidates per search mode before RRF fusion (0 = default 30)", - ), - semanticThreshold: z - .number() - .min(0) - .max(1) - .optional() - .nullable() - .describe( - "Minimum semantic similarity score (0-1) for vector candidates", - ), - limit: z - .number() - .int() - .optional() - .nullable() - .describe("Maximum results (0 = default 10, max: 1000)"), - order_by: z - .string() - .optional() - .nullable() - .describe( - "Sort direction for filter-only searches (no semantic/fulltext). Default: desc", - ), - }, + inputSchema: inputSchema( + { + semantic: z + .string() + .optional() + .nullable() + .describe( + "Natural language query for semantic/meaning search. Use fulltext instead for exact words, identifiers, filenames, errors, or other literal text.", + ), + fulltext: z + .string() + .optional() + .nullable() + .describe("Keywords/phrases for BM25 exact matching"), + grep: z + .string() + .optional() + .nullable() + .describe( + "Regex pattern filter on content (POSIX, case-insensitive). Applied as WHERE filter alongside other filters.", + ), + meta: z + .record(z.string(), z.any()) + .optional() + .nullable() + .describe("Filter by metadata attributes"), + tree: z + .string() + .optional() + .nullable() + .describe( + "Filter by tree path. Bare path (work.projects) matches exactly \u2014 use work.projects.* to include descendants. Supports lquery patterns (*.api.*) and ltxtquery label search (api & v2).", + ), + temporal: z + .object({ + contains: z + .string() + .optional() + .nullable() + .describe("Find memories containing this point in time"), + overlaps: z + .object({ + start: z.string().describe("Start of range"), + end: z.string().describe("End of range"), + }) + .optional() + .nullable() + .describe("Find memories overlapping this range"), + within: z + .object({ + start: z.string().describe("Start of range"), + end: z.string().describe("End of range"), + }) + .optional() + .nullable() + .describe("Find memories fully within this range"), + }) + .optional() + .nullable() + .describe("Temporal filter for search"), + weights: z + .object({ + fulltext: z + .number() + .optional() + .nullable() + .describe("Weight for BM25 keyword matching (0-1)"), + semantic: z + .number() + .optional() + .nullable() + .describe("Weight for semantic similarity (0-1)"), + }) + .optional() + .nullable() + .describe("Weights for hybrid search ranking"), + candidateLimit: z + .number() + .int() + .optional() + .nullable() + .describe( + "Candidates per search mode before RRF fusion (0 = default 30)", + ), + semanticThreshold: z + .number() + .min(0) + .max(1) + .optional() + .nullable() + .describe( + "Minimum semantic similarity score (0-1) for vector candidates", + ), + limit: z + .number() + .int() + .optional() + .nullable() + .describe("Maximum results (0 = default 10, max: 1000)"), + order_by: z + .string() + .optional() + .nullable() + .describe( + "Sort direction for filter-only searches (no semantic/fulltext). Default: desc", + ), + }, + runtime, + ), annotations: { title: "Search Memories", readOnlyHint: true, @@ -327,7 +409,7 @@ Docs: ${docUrl("me_memory_search")}`, }, }, async (args) => { - const result = await client.memory.search({ + const result = await clientFor(args).memory.search({ semantic: args.semantic ?? undefined, fulltext: args.fulltext ?? undefined, grep: args.grep ?? undefined, @@ -373,9 +455,12 @@ Docs: ${docUrl("me_memory_search")}`, Returns full memory including content, tree, meta, temporal, and embedding status. Use after search to get full details, or before update to see current state. Docs: ${docUrl("me_memory_get")}`, - inputSchema: { - id: z.string().describe("The UUID of the memory"), - }, + inputSchema: inputSchema( + { + id: z.string().describe("The UUID of the memory"), + }, + runtime, + ), annotations: { title: "Get Memory", readOnlyHint: true, @@ -384,7 +469,7 @@ Docs: ${docUrl("me_memory_get")}`, }, }, async (args) => { - const result = await client.memory.get({ id: args.id }); + const result = await clientFor(args).memory.get({ id: args.id }); return { content: [ { type: "text" as const, text: JSON.stringify(result, null, 2) }, @@ -403,14 +488,17 @@ Docs: ${docUrl("me_memory_get")}`, The last path segment is the name; the rest is the tree — e.g. "/share/auth/jwt-rotation" is the memory named "jwt-rotation" under "/share/auth". NOT_FOUND if no such named memory exists. Use me_memory_get when you have the UUID. Docs: ${docUrl("me_memory_get_by_path")}`, - inputSchema: { - path: z - .string() - .min(1) - .describe( - 'tree/name path, e.g. "/share/auth/jwt-rotation" or "~/notes/todo"', - ), - }, + inputSchema: inputSchema( + { + path: z + .string() + .min(1) + .describe( + 'tree/name path, e.g. "/share/auth/jwt-rotation" or "~/notes/todo"', + ), + }, + runtime, + ), annotations: { title: "Get Memory by Path", readOnlyHint: true, @@ -419,7 +507,9 @@ Docs: ${docUrl("me_memory_get_by_path")}`, }, }, async (args) => { - const result = await client.memory.getByPath({ path: args.path }); + const result = await clientFor(args).memory.getByPath({ + path: args.path, + }); return { content: [ { type: "text" as const, text: JSON.stringify(result, null, 2) }, @@ -438,51 +528,56 @@ Docs: ${docUrl("me_memory_get_by_path")}`, Provide the ID, the current version_hash from a recent get/search/create/update response, and any fields to change (content, tree, meta, temporal). Null fields remain unchanged. Caution: meta is fully replaced, not merged. Docs: ${docUrl("me_memory_update")}`, - inputSchema: { - id: z.string().describe("The UUID of the memory to update"), - version_hash: z - .string() - .length(32) - .describe( - "Current version_hash for optimistic concurrency control. Get the latest value before updating.", - ), - content: z - .string() - .optional() - .nullable() - .describe("New content (omit or null to keep existing)"), - meta: z - .record(z.string(), z.any()) - .optional() - .nullable() - .describe("New metadata (omit or null to keep existing)"), - tree: z - .string() - .optional() - .nullable() - .describe("New tree path (omit or null to keep existing)"), - name: z - .string() - .optional() - .nullable() - .describe( - 'New leaf name — renames the memory; pass an empty string "" to clear the name (omit or null to keep existing).', - ), - temporal: z - .object({ - start: z.string().describe("ISO timestamp for start of time range"), - end: z - .string() - .optional() - .nullable() - .describe( - "ISO timestamp for end (omit or null for point-in-time)", - ), - }) - .optional() - .nullable() - .describe("Time range for the memory"), - }, + inputSchema: inputSchema( + { + id: z.string().describe("The UUID of the memory to update"), + version_hash: z + .string() + .length(32) + .describe( + "Current version_hash for optimistic concurrency control. Get the latest value before updating.", + ), + content: z + .string() + .optional() + .nullable() + .describe("New content (omit or null to keep existing)"), + meta: z + .record(z.string(), z.any()) + .optional() + .nullable() + .describe("New metadata (omit or null to keep existing)"), + tree: z + .string() + .optional() + .nullable() + .describe("New tree path (omit or null to keep existing)"), + name: z + .string() + .optional() + .nullable() + .describe( + 'New leaf name — renames the memory; pass an empty string "" to clear the name (omit or null to keep existing).', + ), + temporal: z + .object({ + start: z + .string() + .describe("ISO timestamp for start of time range"), + end: z + .string() + .optional() + .nullable() + .describe( + "ISO timestamp for end (omit or null for point-in-time)", + ), + }) + .optional() + .nullable() + .describe("Time range for the memory"), + }, + runtime, + ), annotations: { title: "Update Memory", readOnlyHint: false, @@ -491,7 +586,7 @@ Docs: ${docUrl("me_memory_update")}`, }, }, async (args) => { - const result = await client.memory.update({ + const result = await clientFor(args).memory.update({ id: args.id, versionHash: args.version_hash, content: args.content ?? undefined, @@ -524,9 +619,12 @@ Docs: ${docUrl("me_memory_update")}`, This is irreversible. Consider archiving (meta update) or moving (me_memory_mv) instead. Docs: ${docUrl("me_memory_delete")}`, - inputSchema: { - id: z.string().describe("The UUID of the memory to delete"), - }, + inputSchema: inputSchema( + { + id: z.string().describe("The UUID of the memory to delete"), + }, + runtime, + ), annotations: { title: "Delete Memory", readOnlyHint: false, @@ -535,7 +633,7 @@ Docs: ${docUrl("me_memory_delete")}`, }, }, async (args) => { - const result = await client.memory.delete({ id: args.id }); + const result = await clientFor(args).memory.delete({ id: args.id }); return { content: [ { type: "text" as const, text: JSON.stringify(result, null, 2) }, @@ -554,12 +652,15 @@ Docs: ${docUrl("me_memory_delete")}`, Irreversible. Deletes only that one named memory — use me_memory_delete_tree to remove a whole subtree. Docs: ${docUrl("me_memory_delete_by_path")}`, - inputSchema: { - path: z - .string() - .min(1) - .describe('tree/name path, e.g. "/share/auth/jwt-rotation"'), - }, + inputSchema: inputSchema( + { + path: z + .string() + .min(1) + .describe('tree/name path, e.g. "/share/auth/jwt-rotation"'), + }, + runtime, + ), annotations: { title: "Delete Memory by Path", readOnlyHint: false, @@ -568,7 +669,9 @@ Docs: ${docUrl("me_memory_delete_by_path")}`, }, }, async (args) => { - const result = await client.memory.deleteByPath({ path: args.path }); + const result = await clientFor(args).memory.deleteByPath({ + path: args.path, + }); return { content: [ { type: "text" as const, text: JSON.stringify(result, null, 2) }, @@ -587,17 +690,20 @@ Docs: ${docUrl("me_memory_delete_by_path")}`, Returns count of deleted memories. Use dry_run: true to preview without deleting. Docs: ${docUrl("me_memory_delete_tree")}`, - inputSchema: { - tree: z - .string() - .min(1) - .describe( - "Tree prefix \u2014 all memories at or below this path will be deleted", - ), - dry_run: z - .boolean() - .describe("Preview count without deleting (false to execute)"), - }, + inputSchema: inputSchema( + { + tree: z + .string() + .min(1) + .describe( + "Tree prefix \u2014 all memories at or below this path will be deleted", + ), + dry_run: z + .boolean() + .describe("Preview count without deleting (false to execute)"), + }, + runtime, + ), annotations: { title: "Delete Memory Tree", readOnlyHint: false, @@ -606,7 +712,7 @@ Docs: ${docUrl("me_memory_delete_tree")}`, }, }, async (args) => { - const result = await client.memory.deleteTree({ + const result = await clientFor(args).memory.deleteTree({ tree: args.tree, dryRun: args.dry_run, }); @@ -628,23 +734,26 @@ Docs: ${docUrl("me_memory_delete_tree")}`, The tree input is required and accepts a path prefix (ltree), lquery pattern, or ltxtquery label search. If max_count is supplied and the returned count equals max_count, interpret the result as "at least max_count" rather than an exact total. Docs: ${docUrl("me_memory_count")}`, - inputSchema: { - tree: z - .string() - .min(1) - .describe( - "Tree filter: path prefix (work.projects), lquery pattern (*.api.*), or ltxtquery label search (api & v2)", - ), - max_count: z - .number() - .int() - .min(1) - .optional() - .nullable() - .describe( - "Stop counting after this many matches. If count equals max_count, treat it as at least this many.", - ), - }, + inputSchema: inputSchema( + { + tree: z + .string() + .min(1) + .describe( + "Tree filter: path prefix (work.projects), lquery pattern (*.api.*), or ltxtquery label search (api & v2)", + ), + max_count: z + .number() + .int() + .min(1) + .optional() + .nullable() + .describe( + "Stop counting after this many matches. If count equals max_count, treat it as at least this many.", + ), + }, + runtime, + ), annotations: { title: "Count Memories", readOnlyHint: true, @@ -653,7 +762,7 @@ Docs: ${docUrl("me_memory_count")}`, }, }, async (args) => { - const result = await client.memory.countTree({ + const result = await clientFor(args).memory.countTree({ tree: args.tree, maxCount: args.max_count ?? undefined, }); @@ -675,16 +784,21 @@ Docs: ${docUrl("me_memory_count")}`, The source is preserved and copied memories receive new IDs. Like "cp" in a filesystem, all memories under the source prefix get copied under the destination prefix. Use dry_run to preview. Docs: ${docUrl("me_memory_copy")}`, - inputSchema: { - source: z.string().min(1).describe("Source tree prefix to copy from"), - destination: z - .string() - .min(1) - .describe("Destination tree prefix to copy to"), - dry_run: z - .boolean() - .describe("If true, return count without copying (false to execute)"), - }, + inputSchema: inputSchema( + { + source: z.string().min(1).describe("Source tree prefix to copy from"), + destination: z + .string() + .min(1) + .describe("Destination tree prefix to copy to"), + dry_run: z + .boolean() + .describe( + "If true, return count without copying (false to execute)", + ), + }, + runtime, + ), annotations: { title: "Copy Memories", readOnlyHint: false, @@ -693,7 +807,7 @@ Docs: ${docUrl("me_memory_copy")}`, }, }, async (args) => { - const result = await client.memory.copy({ + const result = await clientFor(args).memory.copy({ source: args.source, destination: args.destination, dryRun: args.dry_run, @@ -716,13 +830,20 @@ Docs: ${docUrl("me_memory_copy")}`, Like "mv" in a filesystem — all memories under the source prefix get their prefix replaced. Use dry_run to preview. Docs: ${docUrl("me_memory_mv")}`, - inputSchema: { - source: z.string().min(1).describe("Source tree prefix to move from"), - destination: z.string().describe("Destination tree prefix to move to"), - dry_run: z - .boolean() - .describe("If true, return count without moving (false to execute)"), - }, + inputSchema: inputSchema( + { + source: z.string().min(1).describe("Source tree prefix to move from"), + destination: z + .string() + .describe("Destination tree prefix to move to"), + dry_run: z + .boolean() + .describe( + "If true, return count without moving (false to execute)", + ), + }, + runtime, + ), annotations: { title: "Move Memories", readOnlyHint: false, @@ -731,7 +852,7 @@ Docs: ${docUrl("me_memory_mv")}`, }, }, async (args) => { - const result = await client.memory.move({ + const result = await clientFor(args).memory.move({ source: args.source, destination: args.destination, dryRun: args.dry_run, @@ -754,21 +875,24 @@ Docs: ${docUrl("me_memory_mv")}`, Shows how memories are organized and how many exist at each level. Use to understand the overall shape of stored knowledge before searching. Docs: ${docUrl("me_memory_tree")}`, - inputSchema: { - tree: z - .string() - .optional() - .nullable() - .describe( - "Root path to display from (e.g., work.projects). Omit or null for full tree.", - ), - levels: z - .number() - .int() - .optional() - .nullable() - .describe("Maximum depth to display (omit or null for unlimited)"), - }, + inputSchema: inputSchema( + { + tree: z + .string() + .optional() + .nullable() + .describe( + "Root path to display from (e.g., work.projects). Omit or null for full tree.", + ), + levels: z + .number() + .int() + .optional() + .nullable() + .describe("Maximum depth to display (omit or null for unlimited)"), + }, + runtime, + ), annotations: { title: "Memory Tree", readOnlyHint: true, @@ -777,7 +901,7 @@ Docs: ${docUrl("me_memory_tree")}`, }, }, async (args) => { - const result = await client.memory.tree({ + const result = await clientFor(args).memory.tree({ tree: args.tree ?? undefined, levels: args.levels && args.levels > 0 ? args.levels : undefined, }); @@ -799,29 +923,32 @@ Docs: ${docUrl("me_memory_tree")}`, Token-efficient: prefer \`path\` over \`content\` to avoid passing large payloads through the conversation. Docs: ${docUrl("me_memory_import")}`, - inputSchema: { - path: z - .string() - .optional() - .nullable() - .describe( - "Absolute path to a file or directory. Directories are imported recursively. Format is inferred from extension (.json, .yaml, .yml, .md, .ndjson, .jsonl). Mutually exclusive with content.", - ), - content: z - .string() - .optional() - .nullable() - .describe( - "Raw content to import (JSON array, YAML array, or Markdown with frontmatter). Mutually exclusive with path.", - ), - format: z - .string() - .optional() - .nullable() - .describe( - "Content format: json, yaml, or md. Required when using content, optional when using path (inferred from extension).", - ), - }, + inputSchema: inputSchema( + { + path: z + .string() + .optional() + .nullable() + .describe( + "Absolute path to a file or directory. Directories are imported recursively. Format is inferred from extension (.json, .yaml, .yml, .md, .ndjson, .jsonl). Mutually exclusive with content.", + ), + content: z + .string() + .optional() + .nullable() + .describe( + "Raw content to import (JSON array, YAML array, or Markdown with frontmatter). Mutually exclusive with path.", + ), + format: z + .string() + .optional() + .nullable() + .describe( + "Content format: json, yaml, or md. Required when using content, optional when using path (inferred from extension).", + ), + }, + runtime, + ), annotations: { title: "Import Memories", readOnlyHint: false, @@ -933,7 +1060,7 @@ Docs: ${docUrl("me_memory_import")}`, // server's request-body limit, and a single failed chunk doesn't // take down the rest of the import. const { results: writeResults, errors } = await batchCreateChunked( - client, + clientFor(args), allMemories, // Re-importing the same content is a no-op: skip rows whose // idempotency key ((tree, name), else id) already exists. @@ -998,61 +1125,64 @@ Docs: ${docUrl("me_memory_import")}`, Token-efficient: use \`path\` to write directly to a file instead of returning content through the conversation. Docs: ${docUrl("me_memory_export")}`, - inputSchema: { - tree: z - .string() - .optional() - .nullable() - .describe("Tree path filter (omit or null for all)"), - meta: z - .record(z.string(), z.any()) - .optional() - .nullable() - .describe("Metadata filter"), - temporal: z - .object({ - contains: z - .string() - .optional() - .nullable() - .describe("Find memories containing this point in time"), - overlaps: z - .object({ - start: z.string().describe("Start of range"), - end: z.string().describe("End of range"), - }) - .optional() - .nullable() - .describe("Find memories overlapping this range"), - within: z - .object({ - start: z.string().describe("Start of range"), - end: z.string().describe("End of range"), - }) - .optional() - .nullable() - .describe("Find memories fully within this range"), - }) - .optional() - .nullable() - .describe("Temporal filter"), - format: z.string().describe("Output format: json, yaml, or md"), - limit: z - .number() - .int() - .optional() - .nullable() - .describe( - "Maximum memories to export (omit or null for default 1000)", - ), - path: z - .string() - .optional() - .nullable() - .describe( - "Absolute file or directory path to write to. For md format, use a directory path to write one .md file per memory. Omit or null to return content inline.", - ), - }, + inputSchema: inputSchema( + { + tree: z + .string() + .optional() + .nullable() + .describe("Tree path filter (omit or null for all)"), + meta: z + .record(z.string(), z.any()) + .optional() + .nullable() + .describe("Metadata filter"), + temporal: z + .object({ + contains: z + .string() + .optional() + .nullable() + .describe("Find memories containing this point in time"), + overlaps: z + .object({ + start: z.string().describe("Start of range"), + end: z.string().describe("End of range"), + }) + .optional() + .nullable() + .describe("Find memories overlapping this range"), + within: z + .object({ + start: z.string().describe("Start of range"), + end: z.string().describe("End of range"), + }) + .optional() + .nullable() + .describe("Find memories fully within this range"), + }) + .optional() + .nullable() + .describe("Temporal filter"), + format: z.string().describe("Output format: json, yaml, or md"), + limit: z + .number() + .int() + .optional() + .nullable() + .describe( + "Maximum memories to export (omit or null for default 1000)", + ), + path: z + .string() + .optional() + .nullable() + .describe( + "Absolute file or directory path to write to. For md format, use a directory path to write one .md file per memory. Omit or null to return content inline.", + ), + }, + runtime, + ), annotations: { title: "Export Memories", readOnlyHint: false, @@ -1075,8 +1205,8 @@ Docs: ${docUrl("me_memory_export")}`, }; } - const result = await client.memory.search( - searchParams as Parameters[0], + const result = await clientFor(args).memory.search( + searchParams as Parameters[0], ); // Strip to import-compatible fields @@ -1254,7 +1384,14 @@ function setupShutdownHandlers(mcpServer: McpServer): void { // Entry Point // ============================================================================= -export interface McpServerOptions { +export type McpSpaceMode = "locked" | "multi"; + +/** Mode and optional locked-space pairing selected before MCP schemas register. */ +export type McpSpaceSelection = + | { spaceMode: "locked"; lockedSpace: string } + | { spaceMode: "multi"; lockedSpace?: undefined }; + +export type McpServerOptions = McpSpaceSelection & { /** Base server URL. */ server: string; /** @@ -1263,20 +1400,25 @@ export interface McpServerOptions { * long-lived, so the access token is resolved per call rather than baked in. */ bearer: BearerSource; - /** Active space slug (sent as X-Me-Space). */ - space: string; -} - -/** - * Run MCP server over stdio. - */ -export async function runMcpServer(options: McpServerOptions): Promise { - const client = createMemoryClient({ - url: options.server, - getToken: options.bearer.getToken, - onUnauthorized: options.bearer.onUnauthorized, - space: options.space, - }); +}; + +/** Build a configured MCP server. Exported for in-memory protocol tests. */ +export function createMcpServer(options: McpServerOptions): McpServer { + // A client captures its headers at construction time. Keep one client per tool + // call so concurrent calls selecting different spaces cannot leak headers. + const getMemoryClient = (space: string) => + createMemoryClient({ + url: options.server, + getToken: options.bearer.getToken, + onUnauthorized: options.bearer.onUnauthorized, + space, + }); + const getUserClient = () => + createUserClient({ + url: options.server, + getToken: options.bearer.getToken, + onUnauthorized: options.bearer.onUnauthorized, + }); const mcpServer = new McpServer( { @@ -1288,10 +1430,24 @@ export async function runMcpServer(options: McpServerOptions): Promise { }, ); - registerTools(mcpServer, client, { - server: options.server, - space: options.space, - }); + const runtime: McpRuntimeContext = + options.spaceMode === "locked" + ? { + server: options.server, + spaceMode: "locked", + lockedSpace: options.lockedSpace, + } + : { server: options.server, spaceMode: "multi" }; + registerTools(mcpServer, getMemoryClient, getUserClient, runtime); + + return mcpServer; +} + +/** + * Run MCP server over stdio. + */ +export async function runMcpServer(options: McpServerOptions): Promise { + const mcpServer = createMcpServer(options); const transport = new StdioServerTransport(); await mcpServer.connect(transport); diff --git a/packages/docs-site/lib/nav.ts b/packages/docs-site/lib/nav.ts index 4cba09f2..e1d7b94a 100644 --- a/packages/docs-site/lib/nav.ts +++ b/packages/docs-site/lib/nav.ts @@ -63,6 +63,7 @@ export const NAV: NavSection[] = [ items: [ { label: "Overview", slug: "mcp/index" }, { label: "Agent Instructions", slug: "mcp/agent-instructions" }, + { label: "me_space_list", slug: "mcp/me_space_list" }, { label: "me_memory_context", slug: "mcp/me_memory_context" }, { label: "me_memory_create", slug: "mcp/me_memory_create" }, { label: "me_memory_get", slug: "mcp/me_memory_get" },