ctx-store 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctx_store-0.7.0.dist-info/METADATA +129 -0
- ctx_store-0.7.0.dist-info/RECORD +36 -0
- ctx_store-0.7.0.dist-info/WHEEL +5 -0
- ctx_store-0.7.0.dist-info/entry_points.txt +3 -0
- ctx_store-0.7.0.dist-info/licenses/LICENSE +21 -0
- ctx_store-0.7.0.dist-info/top_level.txt +2 -0
- ctxserve/__init__.py +3 -0
- ctxserve/__main__.py +5 -0
- ctxserve/auth.py +235 -0
- ctxserve/server.py +294 -0
- ctxstore/VERSION +1 -0
- ctxstore/__init__.py +5 -0
- ctxstore/__main__.py +5 -0
- ctxstore/backend.py +273 -0
- ctxstore/bootstrap.py +168 -0
- ctxstore/cli.py +280 -0
- ctxstore/clock.py +10 -0
- ctxstore/config.py +58 -0
- ctxstore/contract.py +101 -0
- ctxstore/doctor.py +47 -0
- ctxstore/frontmatter.py +115 -0
- ctxstore/fs.py +479 -0
- ctxstore/interface.md +632 -0
- ctxstore/links.py +55 -0
- ctxstore/markdown.py +91 -0
- ctxstore/mcp.py +264 -0
- ctxstore/memory_tool.py +71 -0
- ctxstore/reads.py +308 -0
- ctxstore/search.py +234 -0
- ctxstore/secrets.py +20 -0
- ctxstore/sections.py +211 -0
- ctxstore/spec.py +45 -0
- ctxstore/store.py +401 -0
- ctxstore/upkeep.py +265 -0
- ctxstore/verbs.py +308 -0
- ctxstore/writes.py +194 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctx-store
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: A markdown context store for coding agents: validated writes, budgeted reads, audit trail.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# ctx-store
|
|
12
|
+
|
|
13
|
+
`ctx` is a markdown context store for coding agents: one folder of `*.md` rows with YAML frontmatter as
|
|
14
|
+
the schema, a CLI with validated structured writes (`log`, `fm`, `new`, `move`), budgeted reads
|
|
15
|
+
(`brief`, `find --budget`, `resolve`, `get --section --tail`), a per-actor audit trail and a maintenance
|
|
16
|
+
pass (`init`, `validate`, `doctor`, `maintain`, `migrate`). The same core serves three front-ends: the CLI (Claude
|
|
17
|
+
Code hooks and skills call it), an Anthropic memory-tool handler (`view create str_replace insert delete
|
|
18
|
+
rename`) and an MCP server (stdio for Claude Desktop; HTTP with authentication for claude.ai, as a
|
|
19
|
+
separate package).
|
|
20
|
+
|
|
21
|
+
Production-grade by design: Python stdlib only (≥ 3.10), runs from a plain copy with no install, fixed
|
|
22
|
+
exit-code table, `--json` envelope (`"api": 1`), temp + rename writes under a lock, secret guard on every
|
|
23
|
+
payload, read-only store support, no writable state under the store for reads.
|
|
24
|
+
|
|
25
|
+
## Interface first
|
|
26
|
+
|
|
27
|
+
The interface is the contract: verbs, fixed errors and the doc model. Storage is a backend behind it.
|
|
28
|
+
Markdown files are the default backend, so a store stays a folder you can read and edit; the same calls
|
|
29
|
+
give the same answers on any other backend (`ctxstore/backend.py`, `ctx help backends`).
|
|
30
|
+
|
|
31
|
+
## Try it
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
git clone https://github.com/MdaaaaO/ctx-store && cd ctx-store
|
|
35
|
+
./ctx --version
|
|
36
|
+
./ctx doctor --store tests/fixtures/store-v1
|
|
37
|
+
./ctx help errors
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
No install step: `ctx` runs from a plain copy of the repo on Python ≥ 3.10. The interface (verbs, exit
|
|
41
|
+
codes, error strings, `--json` envelope, environment) is [`docs/interface.md`](docs/interface.md);
|
|
42
|
+
`ctx help` prints from the same file. Tests: `make ci`.
|
|
43
|
+
|
|
44
|
+
## Hooks
|
|
45
|
+
|
|
46
|
+
Every call a harness hook needs is one line. A write names its store; a read may find it by walking up.
|
|
47
|
+
|
|
48
|
+
| When | Call |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Setup on a new machine, or a schema upgrade | `ctx init --store <path> --settings <file> --types <folder>` (add `--upgrade` for a new version: files edited since are kept) |
|
|
51
|
+
| After a change under the store | `ctx validate --changed` (add `--adopt` while writes still come from outside ctx) |
|
|
52
|
+
| Heartbeat | `ctx touch --session <id>` |
|
|
53
|
+
| Session start | `ctx brief --registry` |
|
|
54
|
+
| After a compaction | `ctx brief --session <id>` |
|
|
55
|
+
| A step landed | `ctx log <doc> "<what happened>"` |
|
|
56
|
+
| Session end, or on a timer | `ctx maintain` |
|
|
57
|
+
| CI, after a schema change | `ctx migrate --check` |
|
|
58
|
+
|
|
59
|
+
## Claude Desktop
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{"mcpServers": {"ctx": {"command": "/path/to/ctx-store/ctx", "args": ["mcp"],
|
|
63
|
+
"env": {"CTX_STORE": "/path/to/store", "CTX_ACTOR": "desktop"}}}}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
On Windows with the store and `ctx` inside WSL, Claude Desktop starts it through `wsl.exe`, and the
|
|
67
|
+
environment goes into the arguments:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{"mcpServers": {"ctx": {"command": "wsl.exe",
|
|
71
|
+
"args": ["-e", "env", "CTX_STORE=/home/you/store", "CTX_ACTOR=desktop", "CTX_NO_WALK=1",
|
|
72
|
+
"/home/you/ctx-store/ctx", "mcp"]}}}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Install | `claude_desktop_config.json` is in |
|
|
76
|
+
|---|---|
|
|
77
|
+
| Windows, installer | `%APPDATA%\Claude\` |
|
|
78
|
+
| Windows, Microsoft Store | `%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\` |
|
|
79
|
+
| macOS | `~/Library/Application Support/Claude/` |
|
|
80
|
+
|
|
81
|
+
Tested with the Microsoft Store install on Windows with WSL2: the tools are listed, and `brief`, `find`
|
|
82
|
+
and `log` work. The other two rows are where Claude Desktop documents its config; they were not tested
|
|
83
|
+
here.
|
|
84
|
+
|
|
85
|
+
Every built verb is a tool (`ctx_brief`, `ctx_find`, `ctx_log`, …). `ctx memory` takes the input of
|
|
86
|
+
Anthropic's memory tool on stdin.
|
|
87
|
+
|
|
88
|
+
## claude.ai
|
|
89
|
+
|
|
90
|
+
`ctx-serve` puts the same tools behind HTTP with authentication (OAuth for claude.ai, a bearer token for
|
|
91
|
+
Claude Code), for a store on your machine behind a tunnel: [`docs/connector.md`](docs/connector.md). It is
|
|
92
|
+
a separate package; the core has no network.
|
|
93
|
+
|
|
94
|
+
## Speed
|
|
95
|
+
|
|
96
|
+
Milliseconds per call as a caller sees it: a fresh process each time, interpreter start included
|
|
97
|
+
(about 40 ms of every number). Generated Markdown stores of 3.1 MB and 39.2 MB, 20 runs per call,
|
|
98
|
+
Python 3.14 on Linux under WSL2. The two stores were measured in separate runs on a machine that was
|
|
99
|
+
not idle: compare a row with the first row of its own column. `python3 bench/bench.py` reproduces it.
|
|
100
|
+
|
|
101
|
+
| Call | 225 docs p50 | p95 | 3 000 docs p50 | p95 |
|
|
102
|
+
|---|---:|---:|---:|---:|
|
|
103
|
+
| start of the interpreter (`--version`) | 35 | 38 | 34 | 37 |
|
|
104
|
+
| `brief <doc>` | 35 | 40 | 34 | 39 |
|
|
105
|
+
| `get <doc> --section --tail 5` | 35 | 37 | 35 | 36 |
|
|
106
|
+
| `view <doc>` | 34 | 35 | 35 | 39 |
|
|
107
|
+
| `brief --registry` | 42 | 44 | 127 | 140 |
|
|
108
|
+
| `brief --session <id>` | 42 | 50 | 127 | 134 |
|
|
109
|
+
| `resolve <key>` | 41 | 46 | 103 | 107 |
|
|
110
|
+
| `find <word in one doc>` | 44 | 45 | 155 | 163 |
|
|
111
|
+
| `find <common word>` | 69 | 77 | 173 | 199 |
|
|
112
|
+
| `find --tag` | 41 | 59 | 123 | 144 |
|
|
113
|
+
| `validate` | 54 | 60 | 295 | 309 |
|
|
114
|
+
| `validate --changed` | 49 | 55 | 184 | 194 |
|
|
115
|
+
|
|
116
|
+
**No search cache.** The rule was: a cache enters only above 3 000 docs, with `find` over 200 ms at
|
|
117
|
+
p95, or when a lookup needs ranked search a scan cannot give. At 3 000 docs every `find` stays under
|
|
118
|
+
200 ms. `find` matches terms and ranks its hits as part of the same scan (`ctx help find`), which
|
|
119
|
+
finds 17 of the 18 lookups in `bench/eval/` where the phrase match before it found 6; the one it
|
|
120
|
+
misses is a paraphrase, which an index of words would miss too. A task described in a sentence
|
|
121
|
+
or two finds its doc as well (7 of 7 in the eval). That is where the scan costs most: about 0.1 s for
|
|
122
|
+
25 words on 225 docs, about 0.9 s on 3 000. So the store has no index to build,
|
|
123
|
+
validate or lose. The question returns when a store passes 3 000 docs, or when lookups need
|
|
124
|
+
synonyms.
|
|
125
|
+
|
|
126
|
+
`validate --changed`, the call behind the after-write hook, stays under its 300 ms budget at 3 000 docs.
|
|
127
|
+
|
|
128
|
+
**Status:** P4: every verb except `row` is built; measured; no cache. Design and phasing:
|
|
129
|
+
[#1](https://github.com/MdaaaaO/ctx-store/issues/1). Licence: MIT.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
ctx_store-0.7.0.dist-info/licenses/LICENSE,sha256=QfqxBdBwsjJrZNNocQsz9Ni7_c2Xq3IDw7pFXq3O3Yc,1064
|
|
2
|
+
ctxserve/__init__.py,sha256=gqIdwu3jlNJJRQ_D3_VaHryUROL7URPFWVnF_YC-bt8,178
|
|
3
|
+
ctxserve/__main__.py,sha256=WAhr-TR-SUgzU8lRacTT70n91sBZgxPy6yff-Zyeu3E,55
|
|
4
|
+
ctxserve/auth.py,sha256=Vm4sTbNMs-u0bzwuvvSNjVua-2CPUScfJfV0waoDESQ,10698
|
|
5
|
+
ctxserve/server.py,sha256=FeoGtphbL1uOJAsRoi-ct8uASwRAe7zPuaqP-0H55AQ,14585
|
|
6
|
+
ctxstore/VERSION,sha256=ln2a-xATRmZxZvLnboGRC8GQSI19QdUMoAcunZLwDjI,6
|
|
7
|
+
ctxstore/__init__.py,sha256=MkyaMWFRN3wu6eN8j_lo-W_s13YlqY7pJaf78_5cBhI,155
|
|
8
|
+
ctxstore/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
9
|
+
ctxstore/backend.py,sha256=tqh5gnSE8cCaAYiPWkBVKGwbsZSWr0EJjFwXdYt8qN8,9145
|
|
10
|
+
ctxstore/bootstrap.py,sha256=-leR5k9qSRupiIAZrMjuBG0SaPoJG0gq8xnDP9kem2E,6699
|
|
11
|
+
ctxstore/cli.py,sha256=BjGdF5QhhnYEnFW3004n_bJkMiBAJAtaSzl1faZB84k,11166
|
|
12
|
+
ctxstore/clock.py,sha256=iB0rHTYv-Jk_ieo_QUq7tWI3fF5GsAxojF_iU0sgEHw,191
|
|
13
|
+
ctxstore/config.py,sha256=zwJF-xLXYKWI_7plkX-Xp2GqS-hpLk6leR5c92r-XAg,1894
|
|
14
|
+
ctxstore/contract.py,sha256=jVyUSgTZtfRH5dPESggIp6R9E6dJWvtHwceCd3efKU0,3326
|
|
15
|
+
ctxstore/doctor.py,sha256=PuAIwXtApvMgncdyBy0Lo21zsv4-rkf2MfDgSRyy774,1710
|
|
16
|
+
ctxstore/frontmatter.py,sha256=6UoSXi4jaZURMsIzk-X0Jj62yHDiJKHR0rQ-yC4QeFs,3676
|
|
17
|
+
ctxstore/fs.py,sha256=nc_wKHsryHCR0C5cyRpeleWJ05XX3Ssvw2OdtbWh6iU,16014
|
|
18
|
+
ctxstore/interface.md,sha256=2LSAM-K96hQkrBiVh7pj2fg7BVAylMHkJMverWrpL5w,31827
|
|
19
|
+
ctxstore/links.py,sha256=NPJD9pwIqiwNX_zsLfP2mx2dJQ_XEHRC3t7w7jp5xPE,2129
|
|
20
|
+
ctxstore/markdown.py,sha256=O4b4cVqZXYbftShGkcmlpBB5wL73dj8HWCgadl_jCoc,2594
|
|
21
|
+
ctxstore/mcp.py,sha256=eLjkzZx_tRJwj7XhklwnYhczE5IjL38cCaGeqP6CmbE,12128
|
|
22
|
+
ctxstore/memory_tool.py,sha256=Oz9F_MC8azRy5jiVwP-Fwl96SuPmP28XxPiXg9i2SGg,2574
|
|
23
|
+
ctxstore/reads.py,sha256=BL4IySa1ecOf2fvb8raYiYsm07KbqDSYiYJhiHa-JtI,12951
|
|
24
|
+
ctxstore/search.py,sha256=6hXexfaGiUmP34lYzhDV2zW_0SbSccH-_plxqMPbJGA,9102
|
|
25
|
+
ctxstore/secrets.py,sha256=-GgFAJSQIFGIqh20USYvzt1NweCvmC0BJeNrJi4kOIc,763
|
|
26
|
+
ctxstore/sections.py,sha256=Cn0kKq9osqLNh1K5HS6xhrBdbJVDIjYOyfhgAySchII,7435
|
|
27
|
+
ctxstore/spec.py,sha256=SV-0p83RmQbX2acla0ne-HeNL6PsDdXp19k23RIP3Yw,1395
|
|
28
|
+
ctxstore/store.py,sha256=vHLV5X2ZZGMVRAMufRdRTY9fPYA4YeDhpd7choPNUrA,16447
|
|
29
|
+
ctxstore/upkeep.py,sha256=_El8OVAwAOAWVI3VtYxYJYhRXyC_N9h5A3DVLC6dkBk,12567
|
|
30
|
+
ctxstore/verbs.py,sha256=rMr0LLOMMeyJ0inOsA2ItycUuEPF2TZHQfj-_CHXxIA,13475
|
|
31
|
+
ctxstore/writes.py,sha256=LnSnra6qJOGR8VFFe3OwUQ77EZAFqMBOSwnP-ZwQfoQ,7729
|
|
32
|
+
ctx_store-0.7.0.dist-info/METADATA,sha256=bNN1Q5ibyM2_2edmQR-Qc_NgmfkO0yhNOYfWhcx4zIU,6160
|
|
33
|
+
ctx_store-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
34
|
+
ctx_store-0.7.0.dist-info/entry_points.txt,sha256=AfhG-KGmy0tkoTczBWT3oFnTTfwn9ZMVwJ_nsZlrbPA,75
|
|
35
|
+
ctx_store-0.7.0.dist-info/top_level.txt,sha256=qg8AYNV6uf7hBN0L9iL-XhgLwN1qxcdvtpvR0-afE_4,18
|
|
36
|
+
ctx_store-0.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MdaaaaO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ctxserve/__init__.py
ADDED
ctxserve/__main__.py
ADDED
ctxserve/auth.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Who may call: a static bearer token, or OAuth 2.1 for one owner.
|
|
2
|
+
|
|
3
|
+
The OAuth side is an authorization server small enough to read: dynamic
|
|
4
|
+
client registration, the authorization code grant with PKCE (S256), refresh
|
|
5
|
+
tokens that rotate. There is one resource owner. Consent is the owner's
|
|
6
|
+
passphrase, typed into the page this server shows.
|
|
7
|
+
|
|
8
|
+
Tokens, codes and the passphrase are never stored: the state file holds
|
|
9
|
+
sha256 digests, and lives outside the store.
|
|
10
|
+
"""
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.parse
|
|
21
|
+
|
|
22
|
+
CODE_SECONDS = 300
|
|
23
|
+
ACCESS_SECONDS = 3600
|
|
24
|
+
REFRESH_SECONDS = 30 * 86400
|
|
25
|
+
FORM_SECONDS = 600
|
|
26
|
+
ATTEMPTS, WINDOW = 5, 600 # wrong passphrases allowed per ten minutes
|
|
27
|
+
CLIENTS = 200 # registered clients kept; the oldest go first
|
|
28
|
+
CALLBACKS = (
|
|
29
|
+
re.compile(r"^https://claude\.ai/api/mcp/auth_callback$"),
|
|
30
|
+
re.compile(r"^https://claude\.com/api/mcp/auth_callback$"),
|
|
31
|
+
re.compile(r"^http://(localhost|127\.0\.0\.1)(:\d{1,5})?/callback$"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def digest(value):
|
|
36
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def same(left, right):
|
|
40
|
+
return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def allowed_callback(uri):
|
|
44
|
+
return isinstance(uri, str) and any(rule.match(uri) for rule in CALLBACKS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def challenge_of(verifier):
|
|
48
|
+
raw = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
49
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Refused(Exception):
|
|
53
|
+
"""An OAuth error: (HTTP status, error code, description)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, status, code, text=""):
|
|
56
|
+
super().__init__(code)
|
|
57
|
+
self.status, self.code, self.text = status, code, text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class State:
|
|
61
|
+
"""The server's memory between requests, kept in one JSON file."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, path, clock=time.time):
|
|
64
|
+
self.path, self.clock = path, clock
|
|
65
|
+
self.data = {"clients": {}, "codes": {}, "access": {}, "refresh": {}, "forms": {}, "failures": []}
|
|
66
|
+
if path and os.path.exists(path):
|
|
67
|
+
with open(path, encoding="utf-8") as handle:
|
|
68
|
+
self.data.update(json.load(handle))
|
|
69
|
+
|
|
70
|
+
def save(self):
|
|
71
|
+
now = self.clock()
|
|
72
|
+
for name in ("codes", "access", "refresh", "forms"):
|
|
73
|
+
self.data[name] = {k: v for k, v in self.data[name].items() if v["until"] > now}
|
|
74
|
+
self.data["failures"] = [at for at in self.data["failures"] if at > now - WINDOW]
|
|
75
|
+
clients = sorted(self.data["clients"].items(), key=lambda item: item[1]["at"])
|
|
76
|
+
self.data["clients"] = dict(clients[-CLIENTS:])
|
|
77
|
+
if not self.path:
|
|
78
|
+
return
|
|
79
|
+
os.makedirs(os.path.dirname(self.path), mode=0o700, exist_ok=True)
|
|
80
|
+
temp = f"{self.path}.{os.getpid()}.tmp"
|
|
81
|
+
handle = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
82
|
+
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
83
|
+
json.dump(self.data, out)
|
|
84
|
+
os.replace(temp, self.path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Auth:
|
|
88
|
+
def __init__(self, url, secret=None, token=None, state=None, clock=time.time):
|
|
89
|
+
self.url = url.rstrip("/")
|
|
90
|
+
self.secret, self.token = secret, token
|
|
91
|
+
self.clock = clock
|
|
92
|
+
self.state = state or State(None, clock)
|
|
93
|
+
self.lock = threading.RLock() # requests run in threads; the state is one
|
|
94
|
+
|
|
95
|
+
# --- the resource: who may call /mcp ---
|
|
96
|
+
|
|
97
|
+
def allows(self, header):
|
|
98
|
+
"""Whether an Authorization header carries a token this server gave
|
|
99
|
+
out, or the static one."""
|
|
100
|
+
with self.lock:
|
|
101
|
+
kind, _, value = (header or "").partition(" ")
|
|
102
|
+
if kind.lower() != "bearer" or not value.strip():
|
|
103
|
+
return False
|
|
104
|
+
value = value.strip()
|
|
105
|
+
if self.token and same(value, self.token):
|
|
106
|
+
return True
|
|
107
|
+
entry = self.state.data["access"].get(digest(value))
|
|
108
|
+
return bool(entry) and entry["until"] > self.clock()
|
|
109
|
+
|
|
110
|
+
def challenge(self):
|
|
111
|
+
return f'Bearer resource_metadata="{self.url}/.well-known/oauth-protected-resource"'
|
|
112
|
+
|
|
113
|
+
# --- discovery ---
|
|
114
|
+
|
|
115
|
+
def resource_metadata(self):
|
|
116
|
+
return {"resource": f"{self.url}/mcp", "authorization_servers": [self.url],
|
|
117
|
+
"bearer_methods_supported": ["header"]}
|
|
118
|
+
|
|
119
|
+
def server_metadata(self):
|
|
120
|
+
return {
|
|
121
|
+
"issuer": self.url,
|
|
122
|
+
"authorization_endpoint": f"{self.url}/authorize",
|
|
123
|
+
"token_endpoint": f"{self.url}/token",
|
|
124
|
+
"registration_endpoint": f"{self.url}/register",
|
|
125
|
+
"response_types_supported": ["code"],
|
|
126
|
+
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
127
|
+
"code_challenge_methods_supported": ["S256"],
|
|
128
|
+
"token_endpoint_auth_methods_supported": ["none"],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# --- registration ---
|
|
132
|
+
|
|
133
|
+
def register(self, body):
|
|
134
|
+
with self.lock:
|
|
135
|
+
uris = body.get("redirect_uris") if isinstance(body, dict) else None
|
|
136
|
+
if not isinstance(uris, list) or not uris or not all(allowed_callback(uri) for uri in uris):
|
|
137
|
+
raise Refused(400, "invalid_redirect_uri", "redirect_uris must be Claude's callback or a loopback callback")
|
|
138
|
+
name = body.get("client_name")
|
|
139
|
+
client = secrets.token_urlsafe(24)
|
|
140
|
+
self.state.data["clients"][client] = {
|
|
141
|
+
"redirect_uris": uris, "name": name[:80] if isinstance(name, str) else "", "at": self.clock()}
|
|
142
|
+
self.state.save()
|
|
143
|
+
return {"client_id": client, "redirect_uris": uris, "token_endpoint_auth_method": "none",
|
|
144
|
+
"grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
|
|
145
|
+
"client_name": self.state.data["clients"][client]["name"]}
|
|
146
|
+
|
|
147
|
+
# --- authorization ---
|
|
148
|
+
|
|
149
|
+
def request(self, query):
|
|
150
|
+
"""The checked parameters of an authorization request. A request whose
|
|
151
|
+
client or redirect cannot be trusted is refused here and never
|
|
152
|
+
redirected."""
|
|
153
|
+
with self.lock:
|
|
154
|
+
client = self.state.data["clients"].get(query.get("client_id", ""))
|
|
155
|
+
if not client:
|
|
156
|
+
raise Refused(400, "invalid_client", "unknown client_id")
|
|
157
|
+
redirect = query.get("redirect_uri", "")
|
|
158
|
+
if redirect not in client["redirect_uris"] or not allowed_callback(redirect):
|
|
159
|
+
raise Refused(400, "invalid_request", "redirect_uri is not the client's")
|
|
160
|
+
if query.get("response_type") != "code":
|
|
161
|
+
raise Refused(400, "unsupported_response_type")
|
|
162
|
+
challenge = query.get("code_challenge", "")
|
|
163
|
+
if query.get("code_challenge_method") != "S256" or not re.fullmatch(r"[A-Za-z0-9_-]{43}", challenge):
|
|
164
|
+
raise Refused(400, "invalid_request", "PKCE with S256 is required")
|
|
165
|
+
return {"client_id": query["client_id"], "redirect_uri": redirect, "code_challenge": challenge,
|
|
166
|
+
"state": query.get("state", ""), "name": client["name"]}
|
|
167
|
+
|
|
168
|
+
def form(self, request):
|
|
169
|
+
"""A one-time token that ties the consent page to the request it shows."""
|
|
170
|
+
with self.lock:
|
|
171
|
+
token = secrets.token_urlsafe(24)
|
|
172
|
+
self.state.data["forms"][digest(token)] = {"request": request, "until": self.clock() + FORM_SECONDS}
|
|
173
|
+
self.state.save()
|
|
174
|
+
return token
|
|
175
|
+
|
|
176
|
+
def consent(self, form, passphrase):
|
|
177
|
+
"""The redirect that carries the code, once the owner's passphrase is
|
|
178
|
+
right."""
|
|
179
|
+
with self.lock:
|
|
180
|
+
if not self.secret:
|
|
181
|
+
raise Refused(503, "server_error", "no owner passphrase is set")
|
|
182
|
+
now = self.clock()
|
|
183
|
+
if len([at for at in self.state.data["failures"] if at > now - WINDOW]) >= ATTEMPTS:
|
|
184
|
+
raise Refused(429, "access_denied", "too many wrong passphrases; try again later")
|
|
185
|
+
entry = self.state.data["forms"].pop(digest(form or ""), None)
|
|
186
|
+
if not entry or entry["until"] <= now:
|
|
187
|
+
self.state.save()
|
|
188
|
+
raise Refused(400, "invalid_request", "the page has expired; start again from Claude")
|
|
189
|
+
request = entry["request"]
|
|
190
|
+
if not same(passphrase or "", self.secret):
|
|
191
|
+
self.state.data["failures"].append(now)
|
|
192
|
+
self.state.save()
|
|
193
|
+
raise Refused(403, "access_denied", "wrong passphrase")
|
|
194
|
+
code = secrets.token_urlsafe(32)
|
|
195
|
+
self.state.data["codes"][digest(code)] = {**request, "until": now + CODE_SECONDS}
|
|
196
|
+
self.state.save()
|
|
197
|
+
answer = {"code": code}
|
|
198
|
+
if request["state"]:
|
|
199
|
+
answer["state"] = request["state"]
|
|
200
|
+
joiner = "&" if "?" in request["redirect_uri"] else "?"
|
|
201
|
+
return request["redirect_uri"] + joiner + urllib.parse.urlencode(answer)
|
|
202
|
+
|
|
203
|
+
# --- tokens ---
|
|
204
|
+
|
|
205
|
+
def _issue(self, client):
|
|
206
|
+
now = self.clock()
|
|
207
|
+
access, refresh = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
208
|
+
self.state.data["access"][digest(access)] = {"client_id": client, "until": now + ACCESS_SECONDS}
|
|
209
|
+
self.state.data["refresh"][digest(refresh)] = {"client_id": client, "until": now + REFRESH_SECONDS}
|
|
210
|
+
self.state.save()
|
|
211
|
+
return {"access_token": access, "token_type": "Bearer", "expires_in": ACCESS_SECONDS,
|
|
212
|
+
"refresh_token": refresh}
|
|
213
|
+
|
|
214
|
+
def exchange(self, form):
|
|
215
|
+
with self.lock:
|
|
216
|
+
grant = form.get("grant_type")
|
|
217
|
+
now = self.clock()
|
|
218
|
+
if grant == "authorization_code":
|
|
219
|
+
entry = self.state.data["codes"].pop(digest(form.get("code", "")), None)
|
|
220
|
+
self.state.save() # a code is spent by its first use, right or wrong
|
|
221
|
+
verifier = form.get("code_verifier", "")
|
|
222
|
+
if (not entry or entry["until"] <= now
|
|
223
|
+
or form.get("client_id") != entry["client_id"]
|
|
224
|
+
or form.get("redirect_uri") != entry["redirect_uri"]
|
|
225
|
+
or not re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", verifier)
|
|
226
|
+
or not same(challenge_of(verifier), entry["code_challenge"])):
|
|
227
|
+
raise Refused(400, "invalid_grant")
|
|
228
|
+
return self._issue(entry["client_id"])
|
|
229
|
+
if grant == "refresh_token":
|
|
230
|
+
entry = self.state.data["refresh"].pop(digest(form.get("refresh_token", "")), None)
|
|
231
|
+
self.state.save() # rotation: the old refresh token is gone either way
|
|
232
|
+
if not entry or entry["until"] <= now or form.get("client_id") != entry["client_id"]:
|
|
233
|
+
raise Refused(400, "invalid_grant")
|
|
234
|
+
return self._issue(entry["client_id"])
|
|
235
|
+
raise Refused(400, "unsupported_grant_type")
|