Metadata-Version: 2.5
Name: dvm-contextlayer
Version: 1.0.0
Summary: Personal context layer: a bundle of MCP servers for Obsidian, Microsoft 365, a local knowledge graph and local memories.
Project-URL: Homepage, https://github.com/divyavanmahajan/mycontextlayer
Project-URL: Repository, https://github.com/divyavanmahajan/mycontextlayer
Project-URL: Issues, https://github.com/divyavanmahajan/mycontextlayer/issues
Author: Divya van Mahajan
License-Expression: MIT
License-File: LICENSE
Keywords: kuzu,mcp,memory,microsoft-graph,model-context-protocol,obsidian,outlook,planner
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: keyring>=25
Requires-Dist: kuzu<0.12,>=0.11
Requires-Dist: mcp<3,>=2.0
Requires-Dist: msal>=1.28
Requires-Dist: pydantic>=2.6
Requires-Dist: pyjwt[crypto]>=2.8
Requires-Dist: pyyaml>=6
Requires-Dist: rich>=13
Requires-Dist: starlette>=0.40
Requires-Dist: typer>=0.12
Requires-Dist: uvicorn>=0.30
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# dvm-contextlayer

A personal **context layer** for AI assistants: one Python package that bundles several
[Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers, each exposing a
slice of your own working context. Run them together or separately, over stdio or HTTP,
from any MCP client such as Claude Desktop or Claude Code.

| Server     | What it exposes                                                     | Backend                       |
|------------|----------------------------------------------------------------------|-------------------------------|
| `obsidian` | Full-text search, notes, tags, backlinks and daily notes             | Local Obsidian vaults + SQLite FTS5 |
| `outlook`  | Mail search, folders, messages, calendar events                      | Microsoft Graph (read-only)   |
| `planner`  | Plans, buckets, tasks, my tasks                                      | Microsoft Graph (read-only)   |
| `todo`     | Microsoft To Do lists and tasks                                      | Microsoft Graph (read-only)   |
| `files`    | OneDrive and SharePoint search, browsing and text content            | Microsoft Graph (read-only)   |
| `teams`    | Chats, channels, messages and message search                         | Microsoft Graph (read-only)   |
| `graph`    | A personal knowledge graph: entities, relations, observations, Cypher | Local [Kuzu](https://kuzudb.com) database |
| `memory`   | Mem0-style long-term memories with full-text recall and history      | Local SQLite FTS5             |

Settings live in `~/.mycontextlayer/config.json`. Secrets (the Microsoft token cache) never
touch that file: they are stored in the operating system's secure store through the
[`keyring`](https://pypi.org/project/keyring/) library (Windows Credential Manager, macOS
Keychain, or Secret Service on Linux).

Using GitHub Copilot on Windows? Follow the step-by-step
[walkthrough](docs/walkthrough-copilot-windows.md).

## Install

Python 3.11 or newer.

```bash
# recommended: an isolated tool install
uv tool install dvm-contextlayer
# or
pipx install dvm-contextlayer
# or plain pip
pip install dvm-contextlayer
```

This installs the `mycontextlayer` command.

## Quick start

```bash
mycontextlayer list                 # what is bundled and what is configured
mycontextlayer configure obsidian   # answer the prompts; settings are saved
mycontextlayer serve obsidian       # run it over stdio (what MCP clients launch)
mycontextlayer client-config        # JSON / commands to paste into your MCP client
```

A server refuses to start until it is configured and tells you which command to run:

```
$ mycontextlayer serve outlook
The outlook server is not configured. Run: mycontextlayer configure outlook
```

### Connecting a client

`mycontextlayer client-config` prints ready-made snippets. For Claude Desktop add to
`claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "obsidian": { "command": "mycontextlayer", "args": ["serve", "obsidian"] },
    "outlook":  { "command": "mycontextlayer", "args": ["serve", "outlook"] }
  }
}
```

For Claude Code:

```bash
claude mcp add obsidian -- mycontextlayer serve obsidian
claude mcp add memory   -- mycontextlayer serve memory
```

### HTTP mode (all servers in one process)

```bash
mycontextlayer configure http        # optional: host and port, default 127.0.0.1:8765
mycontextlayer serve --all           # every configured server, Streamable HTTP
mycontextlayer serve outlook -t http # or a single server
```

With `--all` each server is mounted under its own path, for example
`http://127.0.0.1:8765/outlook/mcp`; a single server is served at `/mcp`. `GET /` lists the
endpoints and `GET /health` is a public liveness probe. Use
`mycontextlayer client-config --transport http` for the matching client snippets.

#### HTTP authentication

HTTP mode is authenticated by default. `configure http` lets you pick one of three modes:

| `http.auth` | Who can connect                                                     |
|-------------|----------------------------------------------------------------------|
| `api_key`   | Anyone presenting the random key generated during `configure http` (default) |
| `oauth`     | Anyone presenting a Microsoft Entra access token issued for your app registration |
| `none`      | Everyone on the network (the server warns loudly)                    |

**API key mode.** `configure http` generates a random key, stores it in the OS keyring
(never in `config.json`) and prints it once. Every request except `/health` must carry it,
otherwise the server answers `401`:

```
Authorization: Bearer mcl_...      # or
X-API-Key: mcl_...
```

```bash
mycontextlayer apikey            # show the key again
mycontextlayer apikey --rotate   # replace it (old key stops working immediately)
claude mcp add --transport http outlook http://127.0.0.1:8765/outlook/mcp \
  --header "Authorization: Bearer $(mycontextlayer apikey)"
```

`serve` refuses to start in HTTP mode when auth is on but no key is stored. In containers
without a keyring, pass the key through the `MYCONTEXTLAYER_HTTP_API_KEY` environment
variable instead.

**OAuth mode** (offered once a Microsoft app registration is configured). The HTTP host
becomes an OAuth 2.0 protected resource for that same app registration: clients obtain an
access token from Microsoft Entra for your API scope and send it as a bearer token. Tokens
are validated locally (RS256 signature against the tenant's published keys, expiry,
audience, issuer, tenant and scope), so no round trip to Microsoft is needed per request.

One-time setup on the app registration in the Entra admin center:

1. **Expose an API** → set the Application ID URI to `api://<client id>`.
2. **Add a scope**, e.g. `access_as_user` (admins and users can consent).
3. Optionally add the calling client ids under **Authorized client applications** so users
   are not prompted for consent.
4. Optionally set `requestedAccessTokenVersion` to `2` in the manifest. Both v1.0 and v2.0
   token formats are accepted.

Then run `mycontextlayer configure http`, choose `oauth` and enter the scope name. The host
serves RFC 9728 metadata at `/.well-known/oauth-protected-resource` (and per endpoint, e.g.
`/.well-known/oauth-protected-resource/outlook/mcp`) naming
`https://login.microsoftonline.com/<tenant>/v2.0` as the authorization server, and answers
`401` with a `WWW-Authenticate: Bearer resource_metadata=...` challenge, which is how MCP
clients discover where to authenticate. Entra does not support dynamic client registration,
so the MCP client must be able to use a pre-registered client id (or run its own OAuth flow
for the scope `api://<client id>/<scope>`). Tokens missing the scope get `403`.

`config.json` options under `http.oauth`: `required_scopes` (default `["access_as_user"]`),
`audiences` (extra accepted `aud` values; the client id and `api://<client id>` are always
accepted) and `allowed_tenants` (defaults to the configured tenant, or any tenant when the
tenant is `common`/`organizations`). For a quick test:

```bash
mycontextlayer oauth-token          # device-code sign-in for the API scope, prints a token
curl -H "Authorization: Bearer $(mycontextlayer oauth-token)" http://127.0.0.1:8765/
```

Setting `"auth": "none"` in the `http` section (or choosing `none` during `configure http`)
disables authentication; the server then warns loudly, so only do that on a trusted,
local-only interface. Whatever the mode, put a TLS-terminating reverse proxy in front of the
server before exposing it beyond localhost, since credentials travel in a header.

## Microsoft 365 setup

The `outlook`, `planner`, `todo`, `files` and `teams` servers share one sign-in. You sign in
once with a device code; the refresh token is kept in the OS keyring and renewed
automatically.

### 1. Create an app registration (once)

In the [Microsoft Entra admin center](https://entra.microsoft.com) go to
**App registrations → New registration**:

- Name: anything, e.g. `mycontextlayer`
- Supported account types: *Accounts in this organizational directory only* (or multi-tenant
  if you want to use the same app in several tenants)
- Redirect URI: leave empty
- After creating: **Authentication → Advanced settings → Allow public client flows: Yes**
- Add the Microsoft Graph delegated permissions below (see *Adding API permissions* for the
  exact clicks). Add only the rows for the servers you intend to use; sign-in requests just
  the scopes of the servers you have configured, so an unconsented permission for an unused
  server never blocks you.

| Permission                | Used by   | Admin consent | Notes |
|---------------------------|-----------|---------------|-------|
| `User.Read`               | all       | no            | Always required |
| `Mail.Read`               | outlook   | no            | Your own mailbox |
| `Calendars.Read`          | outlook   | no            | Your own calendars |
| `MailboxSettings.Read`    | outlook   | no            | Time zone and working hours; `get_mailbox_settings` only |
| `Tasks.Read`              | planner, todo | no        | Your Planner tasks, plans you can open, your To Do lists |
| `Group.Read.All`          | planner   | **yes**       | Optional. Enumerates plans of every group you belong to |
| `Files.Read.All`          | files     | no            | Your OneDrive plus files shared with you and site libraries |
| `Sites.Read.All`          | files     | no            | SharePoint site search and site drives |
| `Chat.Read`               | teams     | no            | Your 1:1 and group chats |
| `Team.ReadBasic.All`      | teams     | no            | Names of teams you joined |
| `Channel.ReadBasic.All`   | teams     | no            | Channel names |
| `ChannelMessage.Read.All` | teams     | **yes**       | Reading channel posts and replies |

*Minimum set to read your own data without an administrator*: `User.Read`, `Mail.Read`,
`Calendars.Read`, `MailboxSettings.Read`, `Tasks.Read`, `Files.Read.All`, `Sites.Read.All`,
`Chat.Read`, `Team.ReadBasic.All`, `Channel.ReadBasic.All`. Without `Group.Read.All` the
`planner` server still lists plans shared with you and plans referenced by your tasks.
Without `ChannelMessage.Read.All` the `teams` server can list teams, channels and chats but
channel messages return a permission error. If you only need OneDrive and no SharePoint,
`Files.Read` is enough in place of `Files.Read.All` and `Sites.Read.All` (set it via
`microsoft.extra_scopes` and remove the `files` defaults from the app registration).
Some tenants require admin consent for every permission; in that case ask an admin to grant
consent once for the app.

Only read permissions are requested; the servers never send mail, create tasks or modify
anything in Microsoft 365. Note the **Application (client) ID** and **Directory (tenant) ID**.

#### Adding API permissions

In the portal (works on any browser, no tooling needed):

1. Open [entra.microsoft.com](https://entra.microsoft.com) → **Identity → Applications →
   App registrations → All applications** and click your app (e.g. `mycontextlayer`).
2. In the left menu under **Manage** click **API permissions**. You will see `User.Read`
   already listed; it is added to every new registration.
3. Click **+ Add a permission**. A panel opens on the right.
4. Under *Microsoft APIs* click the big **Microsoft Graph** tile.
5. Click **Delegated permissions** (not *Application permissions*: delegated means "act as
   the signed-in user", which is what the device-code sign-in does).
6. Type a permission name in the **Select permissions** search box, e.g. `Mail.Read`. The
   list is grouped by resource; expand the group (**Mail**) and tick the checkbox next to
   `Mail.Read`. Repeat the search for each permission you need from the table above; the
   ticks are kept while you search.
7. Click **Add permissions** at the bottom. The panel closes and the new rows appear in the
   *Configured permissions* table.
8. Look at the **Admin consent required** column. Rows saying *No* are consented by you when
   you first sign in. Rows saying *Yes* (`Group.Read.All`, `ChannelMessage.Read.All`) need
   an administrator: either click **Grant admin consent for <tenant>** above the table if
   you are one, or send the app name and the permission names to your admin and ask them to
   press that button. Until then the *Status* column shows *Not granted*.
9. Nothing else to save; the permissions take effect immediately. Removing a permission is
   the **…** menu at the end of its row → **Remove permission**.

Your sign-in can only include permissions that are listed here; if `mycontextlayer login`
fails with `AADSTS65001` or "needs admin approval", the permission named in the message is
either missing from this page or still waiting for admin consent.

From a script instead (Azure CLI, signed in with `az login`), the permission ids are looked
up from the Microsoft Graph service principal so nothing is hard-coded:

```bash
APP_ID="<your Application (client) ID>"
GRAPH="00000003-0000-0000-c000-000000000000"   # Microsoft Graph
for perm in User.Read Mail.Read Calendars.Read MailboxSettings.Read Tasks.Read \
            Files.Read.All Sites.Read.All Chat.Read Team.ReadBasic.All Channel.ReadBasic.All; do
  id=$(az ad sp show --id "$GRAPH" --query "oauth2PermissionScopes[?value=='$perm'].id" -o tsv)
  az ad app permission add --id "$APP_ID" --api "$GRAPH" --api-permissions "$id=Scope"
done
az ad app permission admin-consent --id "$APP_ID"   # only if you are an administrator
```

### 2. Configure and sign in

```bash
mycontextlayer configure outlook   # asks for client ID and tenant ID, then offers to sign in
# or explicitly
mycontextlayer configure microsoft
mycontextlayer login
mycontextlayer whoami
mycontextlayer logout              # removes the cached sign-in from the keyring
```

`login` prints a URL and a code; open the URL, enter the code and sign in. The scopes of
every configured Microsoft server are requested in one go so you consent once; configuring
another Microsoft server later checks whether the existing sign-in covers it and offers to
sign in again if not. Extra scopes can be added to `microsoft.extra_scopes` in `config.json`.

## Configuration file

`~/.mycontextlayer/config.json` (override the folder with the `MYCONTEXTLAYER_HOME`
environment variable). Example:

```json
{
  "version": 1,
  "microsoft": {
    "client_id": "00000000-0000-0000-0000-000000000000",
    "tenant_id": "common",
    "account": "you@example.com",
    "extra_scopes": []
  },
  "servers": {
    "obsidian": { "vaults": [{ "name": "Work", "path": "/Users/you/Obsidian/Work" }] },
    "outlook":  { "timezone": "Europe/Amsterdam", "default_page_size": 25 },
    "planner":  { "include_group_plans": true },
    "todo":     {},
    "files":    { "max_content_bytes": 200000 },
    "teams":    {},
    "graph":    { "database_path": "" },
    "memory":   { "database_path": "", "default_user_id": "default" }
  },
  "http": {
    "host": "127.0.0.1",
    "port": 8765,
    "auth": "api_key",
    "oauth": { "required_scopes": ["access_as_user"], "audiences": [], "allowed_tenants": [] }
  }
}
```

A server is "configured" when its section exists. Empty database paths default to
`~/.mycontextlayer/data/`. The Obsidian index lives in `~/.mycontextlayer/index/`. The file
contains no secrets and is written with mode `0600`.

### Secrets

The secrets are the MSAL token cache and the HTTP API key. Both are stored under the
service name `dvm-contextlayer` in the OS keyring, split into chunks small enough for Windows Credential
Manager. If no keyring backend is available (typical on a headless Linux box without a
Secret Service), `configure` and `login` fail with an explanation instead of falling back to
plain files. On Linux desktops install GNOME Keyring or KWallet; on servers, run the
Microsoft servers on a machine with a keyring instead.

## The servers in more detail

**obsidian** indexes `.md` and `.txt` files (ignoring `.obsidian`, `.trash`, hidden folders)
into SQLite FTS5 with YAML frontmatter, inline `#tags` and `[[wikilinks]]`. Tools:
`list_vaults`, `search_notes` (FTS5 syntax), `read_note`, `list_notes`, `recent_notes`,
`get_backlinks`, `list_tags`, `get_daily_note`, `reindex`. Notes are also exposed as
`obsidian://<vault>/<path>` resources. The index refreshes incrementally when it is older
than a minute.

**graph** stores `Entity(name, type, observations[])` nodes and `RELATED(relation)` edges in
an embedded Kuzu database. Tools mirror the reference MCP memory server (`create_entities`,
`create_relations`, `add_observations`, `search_nodes`, `open_nodes`, `read_graph`, delete
variants) plus `get_neighbors`, `graph_stats` and a read-only `query_graph` for Cypher.

**memory** keeps one short fact per row, scoped by `user_id` / `agent_id` / `run_id`, with
BM25 full-text search, duplicate detection and an audit history (`add_memory`,
`add_memories`, `search_memories`, `list_memories`, `get_memory`, `update_memory`,
`delete_memory`, `delete_all_memories`, `memory_history`, `memory_stats`). Extraction of
facts is left to the MCP client, which keeps everything local and model-free.

The Microsoft servers return compact summaries (recipients, dates, previews) and convert HTML
bodies to plain text. See each tool's description in your client for parameters.

## Development

```bash
git clone https://github.com/divyavanmahajan/mycontextlayer
cd mycontextlayer
uv sync --group dev
uv run pytest
uv run ruff check src tests && uv run ruff format --check src tests
uv run mycontextlayer list
```

Point `MYCONTEXTLAYER_HOME` at a scratch folder while developing so your real config is left
alone. The Microsoft servers are tested against a mocked Graph API; there are no live tests.

## Releasing

Versions come from git tags via `hatch-vcs`; there is no version number in the source.

```bash
git tag v0.1.0
git push origin v0.1.0
```

The `Publish to PyPI` workflow runs the tests, builds the sdist and wheel, checks that the
built version equals the tag, publishes to PyPI through
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) and creates a GitHub release
with the artifacts. One-time setup on PyPI: under the `dvm-contextlayer` project (or as a
*pending publisher* before the first release) add a GitHub publisher with owner
`divyavanmahajan`, repository `mycontextlayer`, workflow `publish.yml`, environment `pypi`.
Also create the `pypi` environment in the GitHub repository settings (optionally with required
reviewers). No PyPI token is needed.

## License

MIT
