adapt-server 0.2.3__tar.gz → 0.2.4__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 (112) hide show
  1. {adapt_server-0.2.3 → adapt_server-0.2.4}/PKG-INFO +76 -2
  2. {adapt_server-0.2.3 → adapt_server-0.2.4}/README.md +73 -0
  3. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/__init__.py +1 -1
  4. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/ui.py +1 -1
  5. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/app.py +119 -32
  6. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/cli.py +8 -1
  7. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/check.py +16 -0
  8. adapt_server-0.2.4/adapt/commands/reindex.py +44 -0
  9. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/config.py +13 -2
  10. adapt_server-0.2.4/adapt/mcp.py +162 -0
  11. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/permissions.py +26 -0
  12. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/base.py +24 -0
  13. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/dataset_plugin.py +39 -1
  14. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/html_plugin.py +59 -1
  15. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/markdown_plugin.py +50 -3
  16. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/media_plugin.py +20 -1
  17. adapt_server-0.2.4/adapt/routes.py +127 -0
  18. adapt_server-0.2.4/adapt/routes_search.py +173 -0
  19. adapt_server-0.2.4/adapt/search.py +399 -0
  20. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/admin_base.html +7 -0
  21. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/base.html +10 -0
  22. adapt_server-0.2.4/adapt/templates/search_results.html +57 -0
  23. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/utils/query.py +1 -1
  24. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt_server.egg-info/PKG-INFO +76 -2
  25. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt_server.egg-info/SOURCES.txt +8 -0
  26. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt_server.egg-info/requires.txt +2 -1
  27. {adapt_server-0.2.3 → adapt_server-0.2.4}/pyproject.toml +3 -2
  28. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_admin.py +18 -0
  29. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_config.py +2 -0
  30. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_integration.py +24 -0
  31. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_markdown_plugin.py +3 -1
  32. adapt_server-0.2.4/tests/test_mcp.py +343 -0
  33. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_readonly.py +6 -8
  34. adapt_server-0.2.4/tests/test_routes.py +28 -0
  35. adapt_server-0.2.4/tests/test_search.py +539 -0
  36. adapt_server-0.2.3/adapt/routes.py +0 -69
  37. {adapt_server-0.2.3 → adapt_server-0.2.4}/LICENSE +0 -0
  38. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/__main__.py +0 -0
  39. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/__init__.py +0 -0
  40. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/api_keys.py +0 -0
  41. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/audit_logs.py +0 -0
  42. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/cache.py +0 -0
  43. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/groups.py +0 -0
  44. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/locks.py +0 -0
  45. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/models.py +0 -0
  46. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/permissions.py +0 -0
  47. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/resources.py +0 -0
  48. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/admin/users.py +0 -0
  49. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/api_keys.py +0 -0
  50. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/audit.py +0 -0
  51. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/auth/__init__.py +0 -0
  52. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/auth/dependencies.py +0 -0
  53. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/auth/password.py +0 -0
  54. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/auth/routes.py +0 -0
  55. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/auth/session.py +0 -0
  56. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/cache.py +0 -0
  57. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/__init__.py +0 -0
  58. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/addsuperuser.py +0 -0
  59. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/__init__.py +0 -0
  60. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/add_to_group.py +0 -0
  61. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/create_group.py +0 -0
  62. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/create_permissions.py +0 -0
  63. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/create_user.py +0 -0
  64. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/delete_group.py +0 -0
  65. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/delete_user.py +0 -0
  66. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/list_groups.py +0 -0
  67. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/list_resources.py +0 -0
  68. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/list_users.py +0 -0
  69. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/admin/remove_from_group.py +0 -0
  70. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/list_endpoints.py +0 -0
  71. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/commands/serve.py +0 -0
  72. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/discovery.py +0 -0
  73. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/locks.py +0 -0
  74. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/models.py +0 -0
  75. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/__init__.py +0 -0
  76. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/csv_plugin.py +0 -0
  77. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/excel_plugin.py +0 -0
  78. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/parquet_plugin.py +0 -0
  79. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/plugins/python_plugin.py +0 -0
  80. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/security.py +0 -0
  81. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/security_urls.py +0 -0
  82. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/static/admin/app.js +0 -0
  83. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/static/admin/style.css +0 -0
  84. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/storage.py +0 -0
  85. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/admin/index.html +0 -0
  86. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/datatable.html +0 -0
  87. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/landing.html +0 -0
  88. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/login.html +0 -0
  89. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/media_gallery.html +0 -0
  90. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/media_player.html +0 -0
  91. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/templates/profile.html +0 -0
  92. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt/utils/__init__.py +0 -0
  93. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt_server.egg-info/dependency_links.txt +0 -0
  94. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt_server.egg-info/entry_points.txt +0 -0
  95. {adapt_server-0.2.3 → adapt_server-0.2.4}/adapt_server.egg-info/top_level.txt +0 -0
  96. {adapt_server-0.2.3 → adapt_server-0.2.4}/setup.cfg +0 -0
  97. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_auth.py +0 -0
  98. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_cache.py +0 -0
  99. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_cli_integration.py +0 -0
  100. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_constraints.py +0 -0
  101. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_csv_plugin.py +0 -0
  102. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_dataset_plugin.py +0 -0
  103. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_dependencies.py +0 -0
  104. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_discovery.py +0 -0
  105. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_html_plugin.py +0 -0
  106. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_locks.py +0 -0
  107. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_on_delete.py +0 -0
  108. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_parquet_plugin.py +0 -0
  109. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_phase3.py +0 -0
  110. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_plugin_interface.py +0 -0
  111. {adapt_server-0.2.3 → adapt_server-0.2.4}/tests/test_python_plugin.py +0 -0
  112. {adapt_server-0.2.3 → adapt_server-0.2.4}/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.4
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,73 @@ 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).
138
210
 
139
211
  ## Dataset Mutation Envelope
140
212
 
@@ -156,6 +228,7 @@ adapt serve <root> [--host ... --port ... --tls-cert ... --tls-key ... --reload
156
228
  adapt check <root>
157
229
  adapt addsuperuser <root> --username <name>
158
230
  adapt list-endpoints <root>
231
+ adapt reindex <root> [--force]
159
232
  adapt admin list-resources <root>
160
233
  adapt admin create-permissions <root> __all__
161
234
  ```
@@ -169,6 +242,7 @@ Detailed docs live under `docs/manual/`.
169
242
  - Quick start: [docs/manual/quick_start.md](docs/manual/quick_start.md)
170
243
  - Configuration: [docs/manual/configuration.md](docs/manual/configuration.md)
171
244
  - API reference: [docs/manual/api_reference.md](docs/manual/api_reference.md)
245
+ - MCP guide: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md)
172
246
  - Plugin development: [docs/manual/plugin_development.md](docs/manual/plugin_development.md)
173
247
 
174
248
  ## 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,73 @@ 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).
93
164
 
94
165
  ## Dataset Mutation Envelope
95
166
 
@@ -111,6 +182,7 @@ adapt serve <root> [--host ... --port ... --tls-cert ... --tls-key ... --reload
111
182
  adapt check <root>
112
183
  adapt addsuperuser <root> --username <name>
113
184
  adapt list-endpoints <root>
185
+ adapt reindex <root> [--force]
114
186
  adapt admin list-resources <root>
115
187
  adapt admin create-permissions <root> __all__
116
188
  ```
@@ -124,6 +196,7 @@ Detailed docs live under `docs/manual/`.
124
196
  - Quick start: [docs/manual/quick_start.md](docs/manual/quick_start.md)
125
197
  - Configuration: [docs/manual/configuration.md](docs/manual/configuration.md)
126
198
  - API reference: [docs/manual/api_reference.md](docs/manual/api_reference.md)
199
+ - MCP guide: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md)
127
200
  - Plugin development: [docs/manual/plugin_development.md](docs/manual/plugin_development.md)
128
201
 
129
202
  ## License
@@ -1,3 +1,3 @@
1
1
  """Adapt package namespace."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -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):
@@ -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__)
@@ -40,6 +40,10 @@ def main() -> None:
40
40
  list_parser = subparsers.add_parser("list-endpoints", help="List the auto-generated REST/UI endpoints")
41
41
  list_parser.add_argument("root", nargs="?", default=".", help="Document root to inspect")
42
42
 
43
+ reindex_parser = subparsers.add_parser("reindex", help="Rebuild the full-text search index")
44
+ reindex_parser.add_argument("root", nargs="?", default=".", help="Document root to index")
45
+ reindex_parser.add_argument("--force", action="store_true", help="Reindex even unchanged files")
46
+
43
47
  admin_parser = subparsers.add_parser("admin", help="Admin tasks")
44
48
  admin_subparsers = admin_parser.add_subparsers(dest="admin_command", required=True)
45
49
  admin_list_resources_parser = admin_subparsers.add_parser("list-resources", help="List discovered resources")
@@ -116,6 +120,9 @@ def main() -> None:
116
120
  elif args.command == "list-endpoints":
117
121
  logger.info("Running list-endpoints command with root=%s", args.root)
118
122
  list_endpoints.run_list_endpoints(Path(args.root).resolve())
123
+ elif args.command == "reindex":
124
+ logger.info("Running reindex command with root=%s force=%s", args.root, args.force)
125
+ reindex.run_reindex(Path(args.root).resolve(), force=args.force)
119
126
  elif args.command == "admin":
120
127
  logger.info("Running admin command: %s", args.admin_command)
121
128
  run_admin(args)
@@ -8,6 +8,12 @@ from .. import cache
8
8
 
9
9
  logger = logging.getLogger(__name__)
10
10
 
11
+ # Namespaces claimed by built-in routes. A markdown or HTML file with one of
12
+ # these names mounts at the same path and shadows the built-in.
13
+ RESERVED_NAMESPACES = frozenset({
14
+ "search", "health", "profile", "admin", "docs", "static", "openapi.json",
15
+ })
16
+
11
17
 
12
18
  def run_check(root: Path) -> None:
13
19
  """Check the configuration and discover resources.
@@ -31,6 +37,16 @@ def run_check(root: Path) -> None:
31
37
  print(f"Document root: {config.root}")
32
38
  print(f"SQLite store: {config.db_path} (engine {engine})")
33
39
  print(f"Discovered {count} dataset(s)")
40
+
41
+ # Markdown and HTML resources mount at /<namespace>, so they can collide
42
+ # with built-in routes; datasets live under /api and /ui and cannot.
43
+ for resource in resources:
44
+ if resource.resource_type not in ("markdown", "html"):
45
+ continue
46
+ namespace = resource.relative_path.with_suffix("").as_posix()
47
+ if namespace in RESERVED_NAMESPACES:
48
+ logger.warning("Resource %s shadows the built-in /%s route", resource.path, namespace)
49
+ print(f"WARNING: {resource.relative_path} shadows the built-in /{namespace} route")
34
50
  # Validate TLS if configured
35
51
  if config.tls_cert or config.tls_key:
36
52
  if config.tls_cert and not config.tls_cert.exists():
@@ -0,0 +1,44 @@
1
+ from pathlib import Path
2
+ import logging
3
+
4
+ from ..config import AdaptConfig
5
+ from ..discovery import discover_resources
6
+ from ..storage import init_database
7
+ from .. import cache, search
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def run_reindex(root: Path, force: bool = False) -> None:
13
+ """Rebuild the full-text search index for a document root.
14
+
15
+ The index normally refreshes on server startup. Run this to rebuild it
16
+ without starting the server, or with `force` after changing how a plugin
17
+ produces documents, since incremental refresh keys only on file mtime and
18
+ size and would otherwise skip unchanged files.
19
+
20
+ Args:
21
+ root: The document root to index.
22
+ force: Reindex every resource, even ones that appear unchanged.
23
+ """
24
+ config = AdaptConfig(root=root)
25
+ config.load_from_file()
26
+ init_database(config.db_path)
27
+ cache.configure(str(config.db_path))
28
+ search.configure(str(config.db_path))
29
+
30
+ if not search.is_available():
31
+ logger.error("Search index unavailable: this SQLite build lacks FTS5")
32
+ print("Search index unavailable: this SQLite build lacks FTS5.")
33
+ return
34
+
35
+ resources = discover_resources(config.root, config)
36
+ stats = search.reindex_all(resources, config, force=force)
37
+
38
+ print(f"Document root: {config.root}")
39
+ print(f"Search index: {config.db_path}")
40
+ print(
41
+ f"Indexed {stats['indexed']} resource(s) "
42
+ f"({stats['documents']} document(s)), "
43
+ f"skipped {stats['skipped']}, pruned {stats['pruned']}"
44
+ )
@@ -36,6 +36,8 @@ class AdaptConfig:
36
36
  tls_cert: Path | None = None
37
37
  tls_key: Path | None = None
38
38
  secure_cookies: bool = False # Whether to set secure flag on cookies
39
+ search_on_startup: bool = True # Whether to refresh the search index on startup
40
+ mcp_enabled: bool = True # Whether to mount the MCP server at /mcp
39
41
  plugin_registry: dict[str, str] = field(default_factory=lambda: {
40
42
  ".csv": "adapt.plugins.csv_plugin.CsvPlugin",
41
43
  ".xlsx": "adapt.plugins.excel_plugin.ExcelPlugin",
@@ -139,8 +141,10 @@ class AdaptConfig:
139
141
  "tls_cert": str(self.tls_cert) if self.tls_cert else None,
140
142
  "tls_key": str(self.tls_key) if self.tls_key else None,
141
143
  "secure_cookies": self.secure_cookies,
144
+ "search_on_startup": self.search_on_startup,
142
145
  "readonly": self.readonly,
143
146
  "debug": self.debug,
147
+ "mcp_enabled": self.mcp_enabled,
144
148
  "logging": self.logging.copy(),
145
149
  }
146
150
  with conf_path.open("w") as f:
@@ -159,7 +163,8 @@ class AdaptConfig:
159
163
  """Validate all keys and types in the loaded config dict, exiting on error."""
160
164
  allowed_keys = {
161
165
  "plugin_registry", "host", "port", "tls_cert", "tls_key",
162
- "secure_cookies", "readonly", "debug", "logging",
166
+ "secure_cookies", "search_on_startup", "readonly", "debug", "logging",
167
+ "mcp_enabled",
163
168
  }
164
169
  for key in data:
165
170
  if key not in allowed_keys:
@@ -192,7 +197,7 @@ class AdaptConfig:
192
197
  if not isinstance(data["tls_key"], str):
193
198
  logger.error("tls_key must be str or null")
194
199
  sys.exit(1)
195
- for bool_key in ("secure_cookies", "readonly", "debug"):
200
+ for bool_key in ("secure_cookies", "search_on_startup", "readonly", "debug", "mcp_enabled"):
196
201
  if bool_key in data and not isinstance(data[bool_key], bool):
197
202
  logger.error("%s must be bool", bool_key)
198
203
  sys.exit(1)
@@ -214,10 +219,14 @@ class AdaptConfig:
214
219
  self.tls_key = Path(data["tls_key"])
215
220
  if "secure_cookies" in data:
216
221
  self.secure_cookies = data["secure_cookies"]
222
+ if "search_on_startup" in data:
223
+ self.search_on_startup = data["search_on_startup"]
217
224
  if "readonly" in data:
218
225
  self.readonly = data["readonly"]
219
226
  if "debug" in data:
220
227
  self.debug = data["debug"]
228
+ if "mcp_enabled" in data:
229
+ self.mcp_enabled = data["mcp_enabled"]
221
230
  if "logging" in data:
222
231
  self.logging.update(data["logging"])
223
232
 
@@ -239,3 +248,5 @@ class AdaptConfig:
239
248
  self.readonly = self._parse_env_bool(os.environ["ADAPT_READONLY"], "ADAPT_READONLY")
240
249
  if "ADAPT_DEBUG" in os.environ:
241
250
  self.debug = self._parse_env_bool(os.environ["ADAPT_DEBUG"], "ADAPT_DEBUG")
251
+ if "ADAPT_MCP_ENABLED" in os.environ:
252
+ self.mcp_enabled = self._parse_env_bool(os.environ["ADAPT_MCP_ENABLED"], "ADAPT_MCP_ENABLED")