adapt-server 0.2.3__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {adapt_server-0.2.3 → adapt_server-0.2.5}/PKG-INFO +78 -2
  2. {adapt_server-0.2.3 → adapt_server-0.2.5}/README.md +75 -0
  3. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/__init__.py +1 -1
  4. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/ui.py +1 -1
  5. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/app.py +119 -32
  6. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/cache.py +19 -11
  7. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/cli.py +27 -2
  8. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/addsuperuser.py +19 -4
  9. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/__init__.py +3 -1
  10. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/create_user.py +17 -3
  11. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/check.py +16 -0
  12. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/list_endpoints.py +1 -1
  13. adapt_server-0.2.5/adapt/commands/passwords.py +116 -0
  14. adapt_server-0.2.5/adapt/commands/reindex.py +44 -0
  15. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/config.py +23 -3
  16. adapt_server-0.2.5/adapt/mcp.py +180 -0
  17. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/permissions.py +26 -0
  18. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/__init__.py +3 -1
  19. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/base.py +55 -3
  20. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/csv_plugin.py +9 -12
  21. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/dataset_plugin.py +39 -1
  22. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/excel_plugin.py +6 -8
  23. adapt_server-0.2.5/adapt/plugins/file_plugin.py +108 -0
  24. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/html_plugin.py +52 -3
  25. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/markdown_plugin.py +50 -3
  26. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/media_plugin.py +20 -1
  27. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/parquet_plugin.py +11 -14
  28. adapt_server-0.2.5/adapt/routes.py +127 -0
  29. adapt_server-0.2.5/adapt/routes_search.py +173 -0
  30. adapt_server-0.2.5/adapt/search.py +399 -0
  31. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/admin_base.html +7 -0
  32. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/base.html +10 -0
  33. adapt_server-0.2.5/adapt/templates/search_results.html +57 -0
  34. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/utils/__init__.py +5 -5
  35. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/utils/query.py +1 -1
  36. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt_server.egg-info/PKG-INFO +78 -2
  37. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt_server.egg-info/SOURCES.txt +11 -0
  38. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt_server.egg-info/requires.txt +2 -1
  39. {adapt_server-0.2.3 → adapt_server-0.2.5}/pyproject.toml +3 -2
  40. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_admin.py +144 -1
  41. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_cache.py +7 -0
  42. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_cli_integration.py +20 -0
  43. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_config.py +2 -0
  44. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_csv_plugin.py +63 -1
  45. adapt_server-0.2.5/tests/test_file_plugin.py +109 -0
  46. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_html_plugin.py +8 -2
  47. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_integration.py +84 -1
  48. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_markdown_plugin.py +3 -1
  49. adapt_server-0.2.5/tests/test_mcp.py +355 -0
  50. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_parquet_plugin.py +49 -0
  51. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_plugin_interface.py +2 -0
  52. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_readonly.py +6 -8
  53. adapt_server-0.2.5/tests/test_routes.py +28 -0
  54. adapt_server-0.2.5/tests/test_search.py +548 -0
  55. adapt_server-0.2.3/adapt/routes.py +0 -69
  56. {adapt_server-0.2.3 → adapt_server-0.2.5}/LICENSE +0 -0
  57. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/__main__.py +0 -0
  58. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/__init__.py +0 -0
  59. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/api_keys.py +0 -0
  60. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/audit_logs.py +0 -0
  61. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/cache.py +0 -0
  62. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/groups.py +0 -0
  63. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/locks.py +0 -0
  64. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/models.py +0 -0
  65. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/permissions.py +0 -0
  66. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/resources.py +0 -0
  67. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/admin/users.py +0 -0
  68. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/api_keys.py +0 -0
  69. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/audit.py +0 -0
  70. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/auth/__init__.py +0 -0
  71. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/auth/dependencies.py +0 -0
  72. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/auth/password.py +0 -0
  73. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/auth/routes.py +0 -0
  74. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/auth/session.py +0 -0
  75. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/__init__.py +0 -0
  76. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/add_to_group.py +0 -0
  77. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/create_group.py +0 -0
  78. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/create_permissions.py +0 -0
  79. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/delete_group.py +0 -0
  80. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/delete_user.py +0 -0
  81. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/list_groups.py +0 -0
  82. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/list_resources.py +0 -0
  83. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/list_users.py +0 -0
  84. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/admin/remove_from_group.py +0 -0
  85. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/commands/serve.py +0 -0
  86. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/discovery.py +0 -0
  87. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/locks.py +0 -0
  88. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/models.py +0 -0
  89. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/plugins/python_plugin.py +0 -0
  90. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/security.py +0 -0
  91. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/security_urls.py +0 -0
  92. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/static/admin/app.js +0 -0
  93. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/static/admin/style.css +0 -0
  94. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/storage.py +0 -0
  95. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/admin/index.html +0 -0
  96. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/datatable.html +0 -0
  97. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/landing.html +0 -0
  98. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/login.html +0 -0
  99. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/media_gallery.html +0 -0
  100. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/media_player.html +0 -0
  101. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt/templates/profile.html +0 -0
  102. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt_server.egg-info/dependency_links.txt +0 -0
  103. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt_server.egg-info/entry_points.txt +0 -0
  104. {adapt_server-0.2.3 → adapt_server-0.2.5}/adapt_server.egg-info/top_level.txt +0 -0
  105. {adapt_server-0.2.3 → adapt_server-0.2.5}/setup.cfg +0 -0
  106. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_auth.py +0 -0
  107. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_constraints.py +0 -0
  108. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_dataset_plugin.py +0 -0
  109. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_dependencies.py +0 -0
  110. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_discovery.py +0 -0
  111. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_locks.py +0 -0
  112. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_on_delete.py +0 -0
  113. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_phase3.py +0 -0
  114. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_python_plugin.py +0 -0
  115. {adapt_server-0.2.3 → adapt_server-0.2.5}/tests/test_security_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adapt-server
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs.
5
5
  Author-email: notesofcliff <notesofcliff@gmail.com>
6
6
  License-Expression: MIT
@@ -21,7 +21,7 @@ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
21
21
  Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: fastapi
24
+ Requires-Dist: fastapi>=0.115
25
25
  Requires-Dist: uvicorn
26
26
  Requires-Dist: sqlmodel
27
27
  Requires-Dist: pendulum
@@ -36,6 +36,7 @@ Requires-Dist: pillow
36
36
  Requires-Dist: python-json-logger
37
37
  Requires-Dist: pandas
38
38
  Requires-Dist: fastparquet
39
+ Requires-Dist: mcp<2,>=1.28
39
40
  Provides-Extra: dev
40
41
  Requires-Dist: pytest; extra == "dev"
41
42
  Requires-Dist: httpx; extra == "dev"
@@ -51,6 +52,8 @@ Adapt is a FastAPI server that turns files in a directory into APIs and UIs.
51
52
  - Markdown/HTML become browsable pages
52
53
  - Media files become streaming endpoints and player/gallery UIs
53
54
  - Python files can register custom routers
55
+ - Everything is searchable in one place via full-text `/search`
56
+ - Everything is reachable by agentic tools via an MCP server at `/mcp`
54
57
 
55
58
  ## Quick Start
56
59
 
@@ -81,6 +84,8 @@ Useful URLs:
81
84
  - `/api/<resource>` resource API
82
85
  - `/ui/<resource>` resource UI
83
86
  - `/schema/<resource>` resource schema
87
+ - `/search` full-text search across every resource you can read
88
+ - `/mcp` MCP server for agentic tools (see [MCP Interface](#mcp-interface) below)
84
89
 
85
90
  ## What Adapt Generates
86
91
 
@@ -135,6 +140,75 @@ This reflects the current implementation in the codebase.
135
140
  - Caching with invalidation on mutations
136
141
  - Built-in admin UI for users/groups/permissions/locks/cache/api keys/audit logs
137
142
  - Plugin architecture with companion overrides in `.adapt/`
143
+ - Permission-filtered full-text search across every resource type
144
+ - MCP server for agentic tool access, mounted alongside the REST API
145
+
146
+ ## Full-Text Search
147
+
148
+ `GET /search?q=<query>` searches datasets, Markdown, HTML, and media metadata
149
+ in one ranked list, filtered to what the caller may read — a query term that
150
+ matches a resource you can't see never shows up, and never leaks via the
151
+ result count either.
152
+
153
+ ```bash
154
+ curl -H "X-API-Key: <key>" "http://localhost:8000/search?q=parental+leave"
155
+ ```
156
+
157
+ The index refreshes incrementally on startup (`search_on_startup`, default
158
+ `true`) and can be rebuilt on demand with `adapt reindex <root>`. See the
159
+ [API Reference](docs/manual/api_reference.md#search-endpoint) for query
160
+ parameters and result shape.
161
+
162
+ ## MCP Interface
163
+
164
+ Adapt mounts a [Model Context Protocol](https://modelcontextprotocol.io)
165
+ server at `/mcp`, on the same host/port as everything else, exposing five
166
+ tools that wrap the same permission checks and plugin methods as the REST
167
+ API — `list_resources`, `get_schema`, `read_resource`, `write_resource`, and
168
+ `search`. There's no separate process, no separate API surface, and no
169
+ extra permission model to maintain.
170
+
171
+ Minimal walkthrough — create an account for the agent, grant it read access,
172
+ mint an API key, and connect a client:
173
+
174
+ ```bash
175
+ adapt addsuperuser /path/to/docroot --username admin
176
+ adapt serve /path/to/docroot &
177
+
178
+ adapt admin create-permissions /path/to/docroot __all__
179
+ adapt admin create-user /path/to/docroot --username agent --password <strong-password>
180
+ adapt admin add-to-group /path/to/docroot --username agent --group <resource>_readonly
181
+ ```
182
+
183
+ Log in as `agent` and self-issue an API key from `/profile` (any
184
+ authenticated user can create their own key — no superuser needed), then
185
+ point a client at `/mcp` with that key:
186
+
187
+ ```bash
188
+ # Claude Code CLI
189
+ claude mcp add --transport http adapt http://localhost:8000/mcp \
190
+ --header "X-API-Key: <key>"
191
+ ```
192
+
193
+ ```json
194
+ // Generic MCP client config (Claude Desktop and similar)
195
+ {
196
+ "mcpServers": {
197
+ "adapt": {
198
+ "url": "http://localhost:8000/mcp",
199
+ "headers": { "X-API-Key": "<key>" }
200
+ }
201
+ }
202
+ }
203
+ ```
204
+
205
+ MCP requires an API key on every call — there's no session-cookie or
206
+ anonymous path, since MCP has no concept of a browser session. Set
207
+ `mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
208
+ remove `/mcp` entirely. Full walkthrough, troubleshooting, and the tool
209
+ reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
210
+ For dataset reads, `sort` is the column name and `order` must be `asc` or
211
+ `desc`.
138
212
 
139
213
  ## Dataset Mutation Envelope
140
214
 
@@ -156,6 +230,7 @@ adapt serve <root> [--host ... --port ... --tls-cert ... --tls-key ... --reload
156
230
  adapt check <root>
157
231
  adapt addsuperuser <root> --username <name>
158
232
  adapt list-endpoints <root>
233
+ adapt reindex <root> [--force]
159
234
  adapt admin list-resources <root>
160
235
  adapt admin create-permissions <root> __all__
161
236
  ```
@@ -169,6 +244,7 @@ Detailed docs live under `docs/manual/`.
169
244
  - Quick start: [docs/manual/quick_start.md](docs/manual/quick_start.md)
170
245
  - Configuration: [docs/manual/configuration.md](docs/manual/configuration.md)
171
246
  - API reference: [docs/manual/api_reference.md](docs/manual/api_reference.md)
247
+ - MCP guide: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md)
172
248
  - Plugin development: [docs/manual/plugin_development.md](docs/manual/plugin_development.md)
173
249
 
174
250
  ## License
@@ -6,6 +6,8 @@ Adapt is a FastAPI server that turns files in a directory into APIs and UIs.
6
6
  - Markdown/HTML become browsable pages
7
7
  - Media files become streaming endpoints and player/gallery UIs
8
8
  - Python files can register custom routers
9
+ - Everything is searchable in one place via full-text `/search`
10
+ - Everything is reachable by agentic tools via an MCP server at `/mcp`
9
11
 
10
12
  ## Quick Start
11
13
 
@@ -36,6 +38,8 @@ Useful URLs:
36
38
  - `/api/<resource>` resource API
37
39
  - `/ui/<resource>` resource UI
38
40
  - `/schema/<resource>` resource schema
41
+ - `/search` full-text search across every resource you can read
42
+ - `/mcp` MCP server for agentic tools (see [MCP Interface](#mcp-interface) below)
39
43
 
40
44
  ## What Adapt Generates
41
45
 
@@ -90,6 +94,75 @@ This reflects the current implementation in the codebase.
90
94
  - Caching with invalidation on mutations
91
95
  - Built-in admin UI for users/groups/permissions/locks/cache/api keys/audit logs
92
96
  - Plugin architecture with companion overrides in `.adapt/`
97
+ - Permission-filtered full-text search across every resource type
98
+ - MCP server for agentic tool access, mounted alongside the REST API
99
+
100
+ ## Full-Text Search
101
+
102
+ `GET /search?q=<query>` searches datasets, Markdown, HTML, and media metadata
103
+ in one ranked list, filtered to what the caller may read — a query term that
104
+ matches a resource you can't see never shows up, and never leaks via the
105
+ result count either.
106
+
107
+ ```bash
108
+ curl -H "X-API-Key: <key>" "http://localhost:8000/search?q=parental+leave"
109
+ ```
110
+
111
+ The index refreshes incrementally on startup (`search_on_startup`, default
112
+ `true`) and can be rebuilt on demand with `adapt reindex <root>`. See the
113
+ [API Reference](docs/manual/api_reference.md#search-endpoint) for query
114
+ parameters and result shape.
115
+
116
+ ## MCP Interface
117
+
118
+ Adapt mounts a [Model Context Protocol](https://modelcontextprotocol.io)
119
+ server at `/mcp`, on the same host/port as everything else, exposing five
120
+ tools that wrap the same permission checks and plugin methods as the REST
121
+ API — `list_resources`, `get_schema`, `read_resource`, `write_resource`, and
122
+ `search`. There's no separate process, no separate API surface, and no
123
+ extra permission model to maintain.
124
+
125
+ Minimal walkthrough — create an account for the agent, grant it read access,
126
+ mint an API key, and connect a client:
127
+
128
+ ```bash
129
+ adapt addsuperuser /path/to/docroot --username admin
130
+ adapt serve /path/to/docroot &
131
+
132
+ adapt admin create-permissions /path/to/docroot __all__
133
+ adapt admin create-user /path/to/docroot --username agent --password <strong-password>
134
+ adapt admin add-to-group /path/to/docroot --username agent --group <resource>_readonly
135
+ ```
136
+
137
+ Log in as `agent` and self-issue an API key from `/profile` (any
138
+ authenticated user can create their own key — no superuser needed), then
139
+ point a client at `/mcp` with that key:
140
+
141
+ ```bash
142
+ # Claude Code CLI
143
+ claude mcp add --transport http adapt http://localhost:8000/mcp \
144
+ --header "X-API-Key: <key>"
145
+ ```
146
+
147
+ ```json
148
+ // Generic MCP client config (Claude Desktop and similar)
149
+ {
150
+ "mcpServers": {
151
+ "adapt": {
152
+ "url": "http://localhost:8000/mcp",
153
+ "headers": { "X-API-Key": "<key>" }
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ MCP requires an API key on every call — there's no session-cookie or
160
+ anonymous path, since MCP has no concept of a browser session. Set
161
+ `mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
162
+ remove `/mcp` entirely. Full walkthrough, troubleshooting, and the tool
163
+ reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
164
+ For dataset reads, `sort` is the column name and `order` must be `asc` or
165
+ `desc`.
93
166
 
94
167
  ## Dataset Mutation Envelope
95
168
 
@@ -111,6 +184,7 @@ adapt serve <root> [--host ... --port ... --tls-cert ... --tls-key ... --reload
111
184
  adapt check <root>
112
185
  adapt addsuperuser <root> --username <name>
113
186
  adapt list-endpoints <root>
187
+ adapt reindex <root> [--force]
114
188
  adapt admin list-resources <root>
115
189
  adapt admin create-permissions <root> __all__
116
190
  ```
@@ -124,6 +198,7 @@ Detailed docs live under `docs/manual/`.
124
198
  - Quick start: [docs/manual/quick_start.md](docs/manual/quick_start.md)
125
199
  - Configuration: [docs/manual/configuration.md](docs/manual/configuration.md)
126
200
  - API reference: [docs/manual/api_reference.md](docs/manual/api_reference.md)
201
+ - MCP guide: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md)
127
202
  - Plugin development: [docs/manual/plugin_development.md](docs/manual/plugin_development.md)
128
203
 
129
204
  ## License
@@ -1,3 +1,3 @@
1
1
  """Adapt package namespace."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.5"
@@ -30,4 +30,4 @@ def admin_ui(request: Request):
30
30
 
31
31
  # Serve the admin template
32
32
  logger.debug("Rendering admin UI for superuser %s", user.username)
33
- return request.app.state.templates.TemplateResponse("admin/index.html", context)
33
+ return request.app.state.templates.TemplateResponse(request, "admin/index.html", context)
@@ -12,7 +12,6 @@ from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redi
12
12
  from fastapi.openapi.utils import get_openapi
13
13
  from fastapi.responses import JSONResponse, RedirectResponse
14
14
  from fastapi.middleware.trustedhost import TrustedHostMiddleware
15
- from fastapi.routing import APIRoute
16
15
  from fastapi.staticfiles import StaticFiles
17
16
  from fastapi.templating import Jinja2Templates
18
17
  from sqlmodel import Session, select, delete
@@ -25,11 +24,13 @@ from .admin import router as admin_router
25
24
  from .config import AdaptConfig
26
25
  from .discovery import discover_resources
27
26
  from .permissions import PermissionChecker
28
- from .routes import generate_routes
27
+ from .routes import build_resource_registry, generate_routes, resource_namespaces as _routes_resource_namespaces
28
+ from .routes_search import router as search_router, safe_snippet
29
29
  from .storage import User, DBSession, init_database
30
30
  from .locks import LockManager
31
+ from .mcp import build_mcp_server
31
32
  from .utils import build_accessible_ui_links
32
- from . import cache
33
+ from . import cache, search
33
34
  from .security import (
34
35
  apply_security_headers,
35
36
  build_allowed_hosts,
@@ -45,7 +46,7 @@ logger = logging.getLogger(__name__)
45
46
 
46
47
  _DOCS_INTERNAL_PATHS = frozenset({"/docs", "/docs/", "/docs/oauth2-redirect", "/openapi.json"})
47
48
  _PUBLIC_OPENAPI_PATHS = frozenset({"/", "/auth/login", "/health"})
48
- _AUTHENTICATED_OPENAPI_PATHS = frozenset({"/auth/logout", "/auth/me", "/profile", "/api/apikeys"})
49
+ _AUTHENTICATED_OPENAPI_PATHS = frozenset({"/auth/logout", "/auth/me", "/profile", "/api/apikeys", "/search"})
49
50
 
50
51
 
51
52
  async def cleanup_expired_sessions(engine, interval_hours=24):
@@ -72,13 +73,7 @@ def _normalize_path(path: str) -> str:
72
73
 
73
74
  def _resource_namespaces(resource) -> set[str]:
74
75
  """Return the supported permission namespaces for a discovered resource."""
75
- namespace_no_ext = resource.relative_path.with_suffix("").as_posix()
76
- namespace_with_ext = resource.relative_path.as_posix()
77
- if "sub_namespace" in resource.metadata:
78
- suffix = f"/{resource.metadata['sub_namespace']}"
79
- namespace_no_ext += suffix
80
- namespace_with_ext += suffix
81
- return {namespace_no_ext, namespace_with_ext}
76
+ return set(_routes_resource_namespaces(resource))
82
77
 
83
78
 
84
79
  def _all_resource_namespaces(resources) -> set[str]:
@@ -125,14 +120,14 @@ def _extract_resource_namespace(path: str, all_namespaces: set[str]) -> str | No
125
120
  return None
126
121
 
127
122
 
128
- def _route_is_visible(route: APIRoute, request: Request, user: User | None, all_namespaces: set[str], visible_namespaces: set[str]) -> bool:
129
- """Decide whether a route should appear in the current request's OpenAPI schema."""
130
- path = _normalize_path(route.path)
123
+ def _operation_is_visible(path: str, operation: dict, request: Request, user: User | None, all_namespaces: set[str], visible_namespaces: set[str]) -> bool:
124
+ """Decide whether an OpenAPI operation should appear in the current request's schema."""
125
+ path = _normalize_path(path)
131
126
 
132
127
  if path in _DOCS_INTERNAL_PATHS:
133
128
  return False
134
129
 
135
- if path.startswith("/admin") or "admin" in (route.tags or []):
130
+ if path.startswith("/admin") or "admin" in (operation.get("tags") or []):
136
131
  return bool(user and getattr(user, "is_superuser", False))
137
132
 
138
133
  if path in _PUBLIC_OPENAPI_PATHS:
@@ -160,24 +155,73 @@ def _route_is_visible(route: APIRoute, request: Request, user: User | None, all_
160
155
  return False
161
156
 
162
157
 
158
+ def _iter_schema_refs(node):
159
+ """Yield component schema names referenced anywhere within a JSON-like node."""
160
+ if isinstance(node, dict):
161
+ ref = node.get("$ref")
162
+ if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
163
+ yield ref.removeprefix("#/components/schemas/")
164
+ for value in node.values():
165
+ yield from _iter_schema_refs(value)
166
+ elif isinstance(node, list):
167
+ for item in node:
168
+ yield from _iter_schema_refs(item)
169
+
170
+
171
+ def _prune_unreferenced_components(schema: dict) -> None:
172
+ """Drop component schemas no longer reachable from any kept path.
173
+
174
+ Filtering removes paths but leaves their request/response models behind in
175
+ components.schemas — a non-superuser would otherwise receive the
176
+ field-level shape of admin-only models like UserPublic or Permission.
177
+ """
178
+ components = schema.get("components") or {}
179
+ schemas = components.get("schemas")
180
+ if not schemas:
181
+ return
182
+
183
+ reachable: set[str] = set()
184
+ pending = set(_iter_schema_refs(schema.get("paths", {})))
185
+ while pending:
186
+ name = pending.pop()
187
+ if name in reachable:
188
+ continue
189
+ reachable.add(name)
190
+ pending.update(_iter_schema_refs(schemas.get(name, {})))
191
+
192
+ pruned = {name: definition for name, definition in schemas.items() if name in reachable}
193
+ if pruned:
194
+ components["schemas"] = pruned
195
+ else:
196
+ components.pop("schemas", None)
197
+ if not components:
198
+ schema.pop("components", None)
199
+
200
+
163
201
  def _build_openapi_schema(app: FastAPI, request: Request, user: User | None) -> dict:
164
202
  """Build a filtered OpenAPI schema for the current request context."""
165
- all_namespaces = _all_resource_namespaces(request.app.state.resources)
166
- visible_namespaces = _visible_resource_namespaces(request, user)
167
- visible_routes = [
168
- route
169
- for route in app.routes
170
- if isinstance(route, APIRoute)
171
- and route.include_in_schema
172
- and _route_is_visible(route, request, user, all_namespaces, visible_namespaces)
173
- ]
174
-
175
- return get_openapi(
203
+ schema = get_openapi(
176
204
  title=app.title,
177
205
  version=app.version,
178
206
  description=app.description,
179
- routes=visible_routes,
207
+ routes=app.routes,
180
208
  )
209
+ all_namespaces = _all_resource_namespaces(request.app.state.resources)
210
+ visible_namespaces = _visible_resource_namespaces(request, user)
211
+
212
+ visible_paths: dict[str, dict] = {}
213
+ for path, operations in schema.get("paths", {}).items():
214
+ kept = {
215
+ method: operation
216
+ for method, operation in operations.items()
217
+ if _operation_is_visible(path, operation, request, user, all_namespaces, visible_namespaces)
218
+ }
219
+ if kept:
220
+ visible_paths[path] = kept
221
+
222
+ schema["paths"] = visible_paths
223
+ _prune_unreferenced_components(schema)
224
+ return schema
181
225
 
182
226
 
183
227
  def _visible_resource_paths(request: Request, user: User | None) -> list[str]:
@@ -192,14 +236,25 @@ def _visible_resource_paths(request: Request, user: User | None) -> list[str]:
192
236
 
193
237
  @asynccontextmanager
194
238
  async def lifespan(app: FastAPI):
195
- """Handle application startup and shutdown events."""
239
+ """Handle application startup and shutdown events.
240
+
241
+ A mounted MCP sub-app's own `lifespan` is never invoked by the ASGI
242
+ server: Starlette's `Mount` only forwards `http`/`websocket` scope types,
243
+ never `lifespan`. So the MCP session manager's `run()` context must be
244
+ entered here, alongside the rest of this app's own startup/shutdown.
245
+ """
196
246
  # Startup: Start background cleanup task
197
247
  engine = app.state.db_engine
198
248
  cleanup_task = asyncio.create_task(cleanup_expired_sessions(engine))
199
249
  logger.debug("Application startup: background cleanup task started")
200
-
201
- yield
202
-
250
+
251
+ mcp_server = getattr(app.state, "mcp_server", None)
252
+ if mcp_server is not None:
253
+ async with mcp_server.session_manager.run():
254
+ yield
255
+ else:
256
+ yield
257
+
203
258
  # Shutdown: Could add cleanup logic here if needed
204
259
  cleanup_task.cancel()
205
260
  try:
@@ -219,12 +274,16 @@ def _init_infrastructure(config: AdaptConfig):
219
274
  """
220
275
  engine = init_database(config.db_path)
221
276
  cache.configure(str(config.db_path))
277
+ search.configure(str(config.db_path))
222
278
  lock_manager = LockManager(engine)
223
279
  cleaned = lock_manager.release_stale_locks(max_age_seconds=300)
224
280
  if cleaned > 0:
225
281
  logging.warning("Cleaned %d stale locks on startup", cleaned)
226
282
  resources = discover_resources(config.root, config)
227
283
  logger.debug("Discovered %d resources", len(resources))
284
+ if config.search_on_startup:
285
+ # Incremental: unchanged files are skipped, so restarts stay cheap.
286
+ search.reindex_all(resources, config)
228
287
  return engine, lock_manager, resources
229
288
 
230
289
 
@@ -246,6 +305,7 @@ def create_app(config: AdaptConfig) -> FastAPI:
246
305
  app.state.use_tls = bool(config.tls_cert and config.tls_key)
247
306
  app.state.lock_manager = lock_manager
248
307
  app.state.resources = resources
308
+ app.state.resource_registry = build_resource_registry(resources, config)
249
309
 
250
310
  allowed_hosts = build_allowed_hosts(config.host)
251
311
  if allowed_hosts != ["*"]:
@@ -254,6 +314,9 @@ def create_app(config: AdaptConfig) -> FastAPI:
254
314
  # Set up Jinja2 templates
255
315
  templates_dir = Path(__file__).parent / "templates"
256
316
  templates = Jinja2Templates(directory=str(templates_dir))
317
+ # Search snippets hold raw docroot text; this escapes them and restores only
318
+ # the <mark> highlights. See routes_search.safe_snippet.
319
+ templates.env.filters["safe_snippet"] = safe_snippet
257
320
  app.state.templates = templates
258
321
 
259
322
  # Mount static files
@@ -310,8 +373,32 @@ def create_app(config: AdaptConfig) -> FastAPI:
310
373
  # Mount admin routes
311
374
  app.include_router(admin_router)
312
375
 
376
+ # Mount search routes
377
+ app.include_router(search_router)
378
+
313
379
  # Generate and mount routes
314
- generate_routes(app, resources, config)
380
+ generate_routes(app, app.state.resource_registry)
381
+
382
+ # Mount the MCP server, exposing resources as agent-facing tools
383
+ if config.mcp_enabled:
384
+ mcp_server = build_mcp_server(config)
385
+ mcp_app = mcp_server.streamable_http_app()
386
+ # A mounted sub-app's `request.app` is itself, not the main app (see
387
+ # lifespan() docstring for the related lifespan gotcha) — mirror the
388
+ # slice of state every tool/helper needs onto it.
389
+ mcp_app.state.db_engine = engine
390
+ mcp_app.state.resources = resources
391
+ mcp_app.state.config = config
392
+ mcp_app.state.lock_manager = lock_manager
393
+ mcp_app.state.resource_registry = app.state.resource_registry
394
+ # The outer app's own middleware still runs for /mcp requests (it wraps
395
+ # routing, including the Mount), and by the time it inspects
396
+ # `request.app.state` post-routing, `scope["app"]` has already been
397
+ # overwritten to `mcp_app` — so anything that middleware reads off
398
+ # app.state must be mirrored here too, not just what the tools need.
399
+ mcp_app.state.use_tls = app.state.use_tls
400
+ app.state.mcp_server = mcp_server
401
+ app.mount("/mcp", mcp_app)
315
402
 
316
403
  @app.get("/openapi.json", include_in_schema=False)
317
404
  def openapi_schema(request: Request):
@@ -8,12 +8,22 @@ import threading
8
8
  import logging
9
9
  from datetime import datetime, timedelta, timezone
10
10
  import pickle
11
+ from pathlib import Path
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
14
15
  CACHE_TABLE = "cache"
15
- _lock = threading.Lock()
16
+ _lock = threading.RLock()
16
17
  _db_path: str | None = None
18
+ _CREATE_CACHE_TABLE_SQL = f"""
19
+ CREATE TABLE IF NOT EXISTS {CACHE_TABLE} (
20
+ key TEXT PRIMARY KEY,
21
+ value BLOB,
22
+ expires_at DATETIME,
23
+ resource TEXT,
24
+ user TEXT
25
+ )
26
+ """
17
27
 
18
28
 
19
29
  def _default_db_path() -> str:
@@ -35,24 +45,22 @@ def configure(db_path: str | None) -> None:
35
45
  def _get_conn():
36
46
  """Get a SQLite connection to the cache database."""
37
47
  path = _db_path or _default_db_path()
48
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
38
49
  conn = sqlite3.connect(path, check_same_thread=False)
39
50
  conn.row_factory = sqlite3.Row
51
+ _ensure_cache_table(conn)
40
52
  return conn
41
53
 
54
+
55
+ def _ensure_cache_table(conn: sqlite3.Connection) -> None:
56
+ """Ensure the cache table exists for the active database connection."""
57
+ conn.execute(_CREATE_CACHE_TABLE_SQL)
58
+ conn.commit()
59
+
42
60
  def init_cache_table():
43
61
  """Initialize the cache table in the SQLite database."""
44
62
  with _lock:
45
63
  conn = _get_conn()
46
- conn.execute(f"""
47
- CREATE TABLE IF NOT EXISTS {CACHE_TABLE} (
48
- key TEXT PRIMARY KEY,
49
- value BLOB,
50
- expires_at DATETIME,
51
- resource TEXT,
52
- user TEXT
53
- )
54
- """)
55
- conn.commit()
56
64
  conn.close()
57
65
  logger.debug("Cache table initialized")
58
66
 
@@ -7,7 +7,7 @@ import logging.config
7
7
  from pathlib import Path
8
8
 
9
9
  from .config import AdaptConfig
10
- from .commands import check, addsuperuser, list_endpoints, serve
10
+ from .commands import check, addsuperuser, list_endpoints, reindex, serve
11
11
  from .commands.admin import run_admin
12
12
 
13
13
  logger = logging.getLogger(__name__)
@@ -36,10 +36,20 @@ def main() -> None:
36
36
  user_parser.add_argument("root", nargs="?", default=".", help="Document root containing the SQLite store")
37
37
  user_parser.add_argument("--username", required=True, help="Username for the superuser")
38
38
  user_parser.add_argument("--password", help="Password (will prompt if missing)")
39
+ user_parser.add_argument("--password-confirm", help="Password confirmation for non-interactive use")
40
+ user_parser.add_argument(
41
+ "--allow-weak-password",
42
+ action="store_true",
43
+ help="Allow a weak password without the safety confirmation prompt",
44
+ )
39
45
 
40
46
  list_parser = subparsers.add_parser("list-endpoints", help="List the auto-generated REST/UI endpoints")
41
47
  list_parser.add_argument("root", nargs="?", default=".", help="Document root to inspect")
42
48
 
49
+ reindex_parser = subparsers.add_parser("reindex", help="Rebuild the full-text search index")
50
+ reindex_parser.add_argument("root", nargs="?", default=".", help="Document root to index")
51
+ reindex_parser.add_argument("--force", action="store_true", help="Reindex even unchanged files")
52
+
43
53
  admin_parser = subparsers.add_parser("admin", help="Admin tasks")
44
54
  admin_subparsers = admin_parser.add_subparsers(dest="admin_command", required=True)
45
55
  admin_list_resources_parser = admin_subparsers.add_parser("list-resources", help="List discovered resources")
@@ -61,6 +71,12 @@ def main() -> None:
61
71
  admin_create_user_parser.add_argument("root", nargs="?", default=".", help="Document root")
62
72
  admin_create_user_parser.add_argument("--username", required=True, help="Username")
63
73
  admin_create_user_parser.add_argument("--password", help="Password (will prompt if missing)")
74
+ admin_create_user_parser.add_argument("--password-confirm", help="Password confirmation for non-interactive use")
75
+ admin_create_user_parser.add_argument(
76
+ "--allow-weak-password",
77
+ action="store_true",
78
+ help="Allow a weak password without the safety confirmation prompt",
79
+ )
64
80
  admin_create_user_parser.add_argument("--superuser", action="store_true", help="Create as superuser")
65
81
 
66
82
  admin_delete_user_parser = admin_subparsers.add_parser("delete-user", help="Delete user")
@@ -112,10 +128,19 @@ def main() -> None:
112
128
  check.run_check(Path(args.root).resolve())
113
129
  elif args.command == "addsuperuser":
114
130
  logger.info("Running addsuperuser command for username=%s", args.username)
115
- addsuperuser.run_add_superuser(Path(args.root).resolve(), args.username, args.password)
131
+ addsuperuser.run_add_superuser(
132
+ Path(args.root).resolve(),
133
+ args.username,
134
+ args.password,
135
+ password_confirm=args.password_confirm,
136
+ allow_weak_password=args.allow_weak_password,
137
+ )
116
138
  elif args.command == "list-endpoints":
117
139
  logger.info("Running list-endpoints command with root=%s", args.root)
118
140
  list_endpoints.run_list_endpoints(Path(args.root).resolve())
141
+ elif args.command == "reindex":
142
+ logger.info("Running reindex command with root=%s force=%s", args.root, args.force)
143
+ reindex.run_reindex(Path(args.root).resolve(), force=args.force)
119
144
  elif args.command == "admin":
120
145
  logger.info("Running admin command: %s", args.admin_command)
121
146
  run_admin(args)
@@ -1,4 +1,3 @@
1
- import getpass
2
1
  from pathlib import Path
3
2
  import logging
4
3
 
@@ -7,17 +6,26 @@ from sqlmodel import Session, select
7
6
  from ..config import AdaptConfig
8
7
  from ..storage import User, init_database
9
8
  from ..auth.password import hash_password
9
+ from .passwords import resolve_password
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
13
13
 
14
- def run_add_superuser(root: Path, username: str, password: str | None) -> None:
14
+ def run_add_superuser(
15
+ root: Path,
16
+ username: str,
17
+ password: str | None,
18
+ password_confirm: str | None = None,
19
+ allow_weak_password: bool = False,
20
+ ) -> None:
15
21
  """Create a new superuser account.
16
22
 
17
23
  Args:
18
24
  root: The root directory path for the Adapt configuration.
19
25
  username: The username for the new superuser.
20
26
  password: The password for the new superuser. If None, prompts for input.
27
+ password_confirm: Confirmation for non-interactive password entry.
28
+ allow_weak_password: Allow weak passwords without an interactive override prompt.
21
29
 
22
30
  Returns:
23
31
  None
@@ -27,8 +35,15 @@ def run_add_superuser(root: Path, username: str, password: str | None) -> None:
27
35
  """
28
36
  config = AdaptConfig(root=root)
29
37
  engine = init_database(config.db_path)
38
+ password = resolve_password(
39
+ username=username,
40
+ password=password,
41
+ password_confirm=password_confirm,
42
+ allow_weak_password=allow_weak_password,
43
+ )
30
44
  if password is None:
31
- password = getpass.getpass("Password: ")
45
+ logger.warning("Aborted superuser creation for %s due to password validation", username)
46
+ return
32
47
 
33
48
  hashed = hash_password(password)
34
49
  logger.debug("Hashed password for user %s", username)
@@ -43,4 +58,4 @@ def run_add_superuser(root: Path, username: str, password: str | None) -> None:
43
58
  session.add(user)
44
59
  session.commit()
45
60
  logger.info("Created superuser '%s'", username)
46
- print(f"Created superuser '{username}'")
61
+ print(f"Created superuser '{username}'")