Python API

This reference is generated from Adapt docstrings and signatures.

Application setup

Configuration class for the Adapt application.

Attributes:

Name Type Description
root Path

The root directory path for the application.

readonly bool

Whether the application is in read-only mode.

version str

The version of the application.

tls_cert Path | None

Path to the TLS certificate file.

tls_key Path | None

Path to the TLS key file.

secure_cookies bool

Whether to set secure flags on cookies.

plugin_registry dict[str, str]

Mapping of file extensions to plugin class paths.

logging dict[str, Any]

Logging configuration dictionary for dictConfig.

Source code in adapt/config.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
@dataclass
class AdaptConfig:
    """Configuration class for the Adapt application.

    Attributes:
        root: The root directory path for the application.
        readonly: Whether the application is in read-only mode.
        version: The version of the application.
        tls_cert: Path to the TLS certificate file.
        tls_key: Path to the TLS key file.
        secure_cookies: Whether to set secure flags on cookies.
        plugin_registry: Mapping of file extensions to plugin class paths.
        logging: Logging configuration dictionary for dictConfig.
    """
    root: Path
    host: str = "127.0.0.1"
    port: int = 8000
    readonly: bool = False
    debug: bool = False
    version: str = adapt_version
    tls_cert: Path | None = None
    tls_key: Path | None = None
    secure_cookies: bool = False  # Whether to set secure flag on cookies
    search_on_startup: bool = True  # Whether to refresh the search index on startup
    mcp_enabled: bool = True  # Whether to mount the MCP server at /mcp
    plugin_registry: dict[str, str] = field(default_factory=lambda: {
        ".csv": "adapt.plugins.csv_plugin.CsvPlugin",
        ".xlsx": "adapt.plugins.excel_plugin.ExcelPlugin",
        ".xls": "adapt.plugins.excel_plugin.ExcelPlugin",
        ".parquet": "adapt.plugins.parquet_plugin.ParquetPlugin",
        ".py": "adapt.plugins.python_plugin.PythonHandlerPlugin",
        ".html": "adapt.plugins.html_plugin.HtmlPlugin",
        ".txt": "adapt.plugins.file_plugin.FilePlugin",
        ".pdf": "adapt.plugins.file_plugin.FilePlugin",
        ".json": "adapt.plugins.file_plugin.FilePlugin",
        ".xml": "adapt.plugins.file_plugin.FilePlugin",
        ".svg": "adapt.plugins.file_plugin.FilePlugin",
        ".png": "adapt.plugins.file_plugin.FilePlugin",
        ".jpg": "adapt.plugins.file_plugin.FilePlugin",
        ".jpeg": "adapt.plugins.file_plugin.FilePlugin",
        ".gif": "adapt.plugins.file_plugin.FilePlugin",
        ".webp": "adapt.plugins.file_plugin.FilePlugin",
        ".md": "adapt.plugins.markdown_plugin.MarkdownPlugin",
        ".mp4": "adapt.plugins.media_plugin.MediaPlugin",
        ".mp3": "adapt.plugins.media_plugin.MediaPlugin",
        ".avi": "adapt.plugins.media_plugin.MediaPlugin",
        ".mkv": "adapt.plugins.media_plugin.MediaPlugin",
        ".webm": "adapt.plugins.media_plugin.MediaPlugin",
        ".ogg": "adapt.plugins.media_plugin.MediaPlugin",
        ".wav": "adapt.plugins.media_plugin.MediaPlugin",
    })
    logging: dict[str, Any] = field(default_factory=lambda: {
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "json": {
                "class": "pythonjsonlogger.jsonlogger.JsonFormatter",
                "format": "%(asctime)s %(name)s %(levelname)s %(message)s"
            }
        },
        "handlers": {
            "console": {
                "class": "logging.StreamHandler",
                "formatter": "json",
                "stream": "ext://sys.stdout"
            }
        },
        "root": {
            "level": "INFO",
            "handlers": ["console"]
        }
    })

    def __post_init__(self) -> None:
        """Post-initialization to resolve paths and set database path."""
        self.root = self.root.resolve()
        self.db_path = self.root / ".adapt" / "adapt.db"
        logger.debug("Config initialized: root=%s, db_path=%s, readonly=%s", self.root, self.db_path, self.readonly)

    @staticmethod
    def _parse_env_bool(value: str, key: str) -> bool:
        lowered = value.strip().lower()
        if lowered in {"1", "true", "yes", "on"}:
            return True
        if lowered in {"0", "false", "no", "off"}:
            return False
        logger.error("Invalid boolean value for %s: %s", key, value)
        sys.exit(1)

    def get_plugin_factory(self, extension: str) -> Callable[..., Any]:
        """Get the plugin factory for a given file extension.

        Args:
            extension: The file extension (e.g., '.csv').

        Returns:
            The plugin class factory.

        Raises:
            ValueError: If no plugin is registered for the extension.
        """
        normalized = extension.lower()
        dotted = self.plugin_registry.get(normalized)
        if not dotted:
            logger.error("No plugin registered for extension '%s'", extension)
            raise ValueError(f"No plugin registered for '{extension}'")

        module_name, class_name = dotted.rsplit(".", 1)
        module = import_module(module_name)
        plugin_cls = getattr(module, class_name)
        logger.debug("Loaded plugin %s for extension '%s'", dotted, extension)
        return plugin_cls

    def load_from_file(self) -> None:
        """Load configuration from DOCROOT/.adapt/conf.json, creating it with defaults if missing."""
        conf_path = self.root / ".adapt" / "conf.json"
        (self.root / ".adapt").mkdir(parents=True, exist_ok=True)
        self._ensure_config_file(conf_path)
        data = self._read_config_file(conf_path)
        self._validate_config(data, conf_path)
        self._apply_file_config(data)
        self._apply_env_overrides()
        if self.debug:
            self.logging.setdefault("root", {})
            self.logging["root"]["level"] = "DEBUG"

    def _ensure_config_file(self, conf_path: Path) -> None:
        """Write conf.json with current defaults if it does not yet exist."""
        if conf_path.exists():
            return
        defaults = {
            "plugin_registry": self.plugin_registry.copy(),
            "host": self.host,
            "port": self.port,
            "tls_cert": str(self.tls_cert) if self.tls_cert else None,
            "tls_key": str(self.tls_key) if self.tls_key else None,
            "secure_cookies": self.secure_cookies,
            "search_on_startup": self.search_on_startup,
            "readonly": self.readonly,
            "debug": self.debug,
            "mcp_enabled": self.mcp_enabled,
            "logging": self.logging.copy(),
        }
        with conf_path.open("w") as f:
            json.dump(defaults, f, indent=2)

    def _read_config_file(self, conf_path: Path) -> dict:
        """Read and JSON-parse conf.json, exiting on parse error."""
        try:
            with conf_path.open() as f:
                return json.load(f)
        except json.JSONDecodeError as e:
            logger.error("Invalid JSON in %s: %s", conf_path, e)
            sys.exit(1)

    def _validate_config(self, data: dict, conf_path: Path) -> None:
        """Validate all keys and types in the loaded config dict, exiting on error."""
        allowed_keys = {
            "plugin_registry", "host", "port", "tls_cert", "tls_key",
            "secure_cookies", "search_on_startup", "readonly", "debug", "logging",
            "mcp_enabled",
        }
        for key in data:
            if key not in allowed_keys:
                logger.error("Unknown key in %s: %s", conf_path, key)
                sys.exit(1)

        if "plugin_registry" in data:
            if not isinstance(data["plugin_registry"], dict):
                logger.error("plugin_registry must be a dict")
                sys.exit(1)
            for ext, path in data["plugin_registry"].items():
                if not isinstance(ext, str) or not isinstance(path, str):
                    logger.error("plugin_registry values must be str: str")
                    sys.exit(1)
        if "host" in data and not isinstance(data["host"], str):
            logger.error("host must be str")
            sys.exit(1)
        if "port" in data:
            if not isinstance(data["port"], int):
                logger.error("port must be int")
                sys.exit(1)
            if not (1 <= data["port"] <= 65535):
                logger.error("port must be between 1 and 65535")
                sys.exit(1)
        if "tls_cert" in data and data["tls_cert"] is not None:
            if not isinstance(data["tls_cert"], str):
                logger.error("tls_cert must be str or null")
                sys.exit(1)
        if "tls_key" in data and data["tls_key"] is not None:
            if not isinstance(data["tls_key"], str):
                logger.error("tls_key must be str or null")
                sys.exit(1)
        for bool_key in ("secure_cookies", "search_on_startup", "readonly", "debug", "mcp_enabled"):
            if bool_key in data and not isinstance(data[bool_key], bool):
                logger.error("%s must be bool", bool_key)
                sys.exit(1)
        if "logging" in data and not isinstance(data["logging"], dict):
            logger.error("logging must be a dict")
            sys.exit(1)

    def _apply_file_config(self, data: dict) -> None:
        """Merge validated file config dict into this instance."""
        if "plugin_registry" in data:
            self.plugin_registry.update(data["plugin_registry"])
        if "host" in data:
            self.host = data["host"]
        if "port" in data:
            self.port = data["port"]
        if "tls_cert" in data and data["tls_cert"]:
            self.tls_cert = Path(data["tls_cert"])
        if "tls_key" in data and data["tls_key"]:
            self.tls_key = Path(data["tls_key"])
        if "secure_cookies" in data:
            self.secure_cookies = data["secure_cookies"]
        if "search_on_startup" in data:
            self.search_on_startup = data["search_on_startup"]
        if "readonly" in data:
            self.readonly = data["readonly"]
        if "debug" in data:
            self.debug = data["debug"]
        if "mcp_enabled" in data:
            self.mcp_enabled = data["mcp_enabled"]
        if "logging" in data:
            self.logging.update(data["logging"])

    def _apply_env_overrides(self) -> None:
        """Apply ADAPT_* environment variable overrides to this instance."""
        if "ADAPT_HOST" in os.environ:
            self.host = os.environ["ADAPT_HOST"]
        if "ADAPT_PORT" in os.environ:
            try:
                port = int(os.environ["ADAPT_PORT"])
            except ValueError:
                logger.error("ADAPT_PORT must be an integer")
                sys.exit(1)
            if not (1 <= port <= 65535):
                logger.error("ADAPT_PORT must be between 1 and 65535")
                sys.exit(1)
            self.port = port
        if "ADAPT_READONLY" in os.environ:
            self.readonly = self._parse_env_bool(os.environ["ADAPT_READONLY"], "ADAPT_READONLY")
        if "ADAPT_DEBUG" in os.environ:
            self.debug = self._parse_env_bool(os.environ["ADAPT_DEBUG"], "ADAPT_DEBUG")
        if "ADAPT_MCP_ENABLED" in os.environ:
            self.mcp_enabled = self._parse_env_bool(os.environ["ADAPT_MCP_ENABLED"], "ADAPT_MCP_ENABLED")

__post_init__()

Post-initialization to resolve paths and set database path.

Source code in adapt/config.py
89
90
91
92
93
def __post_init__(self) -> None:
    """Post-initialization to resolve paths and set database path."""
    self.root = self.root.resolve()
    self.db_path = self.root / ".adapt" / "adapt.db"
    logger.debug("Config initialized: root=%s, db_path=%s, readonly=%s", self.root, self.db_path, self.readonly)

get_plugin_factory(extension)

Get the plugin factory for a given file extension.

Parameters:

Name Type Description Default
extension str

The file extension (e.g., '.csv').

required

Returns:

Type Description
Callable[..., Any]

The plugin class factory.

Raises:

Type Description
ValueError

If no plugin is registered for the extension.

Source code in adapt/config.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def get_plugin_factory(self, extension: str) -> Callable[..., Any]:
    """Get the plugin factory for a given file extension.

    Args:
        extension: The file extension (e.g., '.csv').

    Returns:
        The plugin class factory.

    Raises:
        ValueError: If no plugin is registered for the extension.
    """
    normalized = extension.lower()
    dotted = self.plugin_registry.get(normalized)
    if not dotted:
        logger.error("No plugin registered for extension '%s'", extension)
        raise ValueError(f"No plugin registered for '{extension}'")

    module_name, class_name = dotted.rsplit(".", 1)
    module = import_module(module_name)
    plugin_cls = getattr(module, class_name)
    logger.debug("Loaded plugin %s for extension '%s'", dotted, extension)
    return plugin_cls

load_from_file()

Load configuration from DOCROOT/.adapt/conf.json, creating it with defaults if missing.

Source code in adapt/config.py
129
130
131
132
133
134
135
136
137
138
139
140
def load_from_file(self) -> None:
    """Load configuration from DOCROOT/.adapt/conf.json, creating it with defaults if missing."""
    conf_path = self.root / ".adapt" / "conf.json"
    (self.root / ".adapt").mkdir(parents=True, exist_ok=True)
    self._ensure_config_file(conf_path)
    data = self._read_config_file(conf_path)
    self._validate_config(data, conf_path)
    self._apply_file_config(data)
    self._apply_env_overrides()
    if self.debug:
        self.logging.setdefault("root", {})
        self.logging["root"]["level"] = "DEBUG"

Create and configure the FastAPI application.

Parameters:

Name Type Description Default
config AdaptConfig

The application configuration.

required

Returns:

Type Description
FastAPI

The configured FastAPI app instance.

Source code in adapt/app.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
def create_app(config: AdaptConfig) -> FastAPI:
    """Create and configure the FastAPI application.

    Args:
        config: The application configuration.

    Returns:
        The configured FastAPI app instance.
    """
    logger.debug("Creating FastAPI app with config: %s", config)
    engine, lock_manager, resources = _init_infrastructure(config)

    app = FastAPI(title="Adapt", version=config.version, lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
    app.state.config = config
    app.state.db_engine = engine
    app.state.use_tls = bool(config.tls_cert and config.tls_key)
    app.state.lock_manager = lock_manager
    app.state.resources = resources
    app.state.resource_registry = build_resource_registry(resources, config)

    allowed_hosts = build_allowed_hosts(config.host)
    if allowed_hosts != ["*"]:
        app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)

    # Set up Jinja2 templates
    templates_dir = Path(__file__).parent / "templates"
    templates = Jinja2Templates(directory=str(templates_dir))
    # Search snippets hold raw docroot text; this escapes them and restores only
    # the <mark> highlights. See routes_search.safe_snippet.
    templates.env.filters["safe_snippet"] = safe_snippet
    app.state.templates = templates

    # Mount static files
    static_dir = Path(__file__).parent / "static"
    app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")

    # Authentication middleware
    @app.middleware("http")
    async def security_middleware(request: Request, call_next):
        """Middleware for CSRF validation and response security headers."""
        csrf_token = request.cookies.get("adapt_csrf")
        should_set_csrf_cookie = False
        if not csrf_token:
            csrf_token = generate_csrf_token()
            should_set_csrf_cookie = True

        request.state.csrf_token = csrf_token

        csrf_error = await validate_csrf(request)
        if csrf_error:
            apply_security_headers(csrf_error, use_tls=request.app.state.use_tls)
            if should_set_csrf_cookie:
                set_csrf_cookie(csrf_error, csrf_token, secure=request.app.state.config.secure_cookies)
            return csrf_error

        response = await call_next(request)
        apply_security_headers(response, use_tls=request.app.state.use_tls)
        if should_set_csrf_cookie:
            set_csrf_cookie(response, csrf_token, secure=request.app.state.config.secure_cookies)
        return response

    @app.middleware("http")
    async def auth_middleware(request: Request, call_next):
        """Middleware to handle user authentication via session cookies."""
        token = request.cookies.get("adapt_session")
        request.state.user = None
        if token:
            with Session(engine) as db:
                sess = get_session(db, token)  # Uses fixed function with expiration check
                if sess:
                    user = db.get(User, sess.user_id)
                    request.state.user = user
                    logger.debug("Authenticated user %s for request %s", user.username if user else None, request.url)
                else:
                    logger.debug("Invalid or expired session token for request %s", request.url)
        else:
            logger.debug("No session token in request %s", request.url)
        response = await call_next(request)
        return response

    # Mount authentication routes
    app.include_router(auth_router, prefix="", tags=["auth"])

    # Mount admin routes
    app.include_router(admin_router)

    # Mount search routes
    app.include_router(search_router)

    # Generate and mount routes
    generate_routes(app, app.state.resource_registry)

    # Mount the MCP server, exposing resources as agent-facing tools
    if config.mcp_enabled:
        mcp_server = build_mcp_server(config)
        mcp_app = mcp_server.streamable_http_app()
        # A mounted sub-app's `request.app` is itself, not the main app (see
        # lifespan() docstring for the related lifespan gotcha) — mirror the
        # slice of state every tool/helper needs onto it.
        mcp_app.state.db_engine = engine
        mcp_app.state.resources = resources
        mcp_app.state.config = config
        mcp_app.state.lock_manager = lock_manager
        mcp_app.state.resource_registry = app.state.resource_registry
        # The outer app's own middleware still runs for /mcp requests (it wraps
        # routing, including the Mount), and by the time it inspects
        # `request.app.state` post-routing, `scope["app"]` has already been
        # overwritten to `mcp_app` — so anything that middleware reads off
        # app.state must be mirrored here too, not just what the tools need.
        mcp_app.state.use_tls = app.state.use_tls
        app.state.mcp_server = mcp_server
        app.mount("/mcp", mcp_app)

    @app.get("/openapi.json", include_in_schema=False)
    def openapi_schema(request: Request):
        """Return an OpenAPI document filtered to the current user's visible routes."""
        user = get_current_user(request)
        return JSONResponse(_build_openapi_schema(app, request, user))

    @app.get("/docs", include_in_schema=False)
    @app.get("/docs/", include_in_schema=False)
    def swagger_ui():
        """Render Swagger UI against the request-filtered OpenAPI schema."""
        return get_swagger_ui_html(
            openapi_url="/openapi.json",
            title=f"{app.title} - API Docs",
            oauth2_redirect_url="/docs/oauth2-redirect",
        )

    @app.get("/docs/oauth2-redirect", include_in_schema=False)
    def swagger_ui_redirect():
        """Serve the Swagger UI OAuth redirect helper."""
        return get_swagger_ui_oauth2_redirect_html()

    @app.get("/health", tags=["system"])
    async def health(request: Request, user=Depends(get_current_user)):
        """
        Health check endpoint.
        - Unauthenticated: returns minimal info (status, version, timestamp)
        - Authenticated: adds uptime, cache size, and endpoint count
        """
        info = {
            "status": "ok",
            "version": getattr(config, "version", "unknown"),
            "timestamp": datetime.now(timezone.utc).isoformat() + "Z"
        }
        if user:
            # Add extra info for authenticated users
            uptime = time.time() - _START_TIME
            # Try to get cache size if available
            cache_size = None
            try:
                conn = cache._get_conn()
                cursor = conn.cursor()
                cursor.execute(f"SELECT COUNT(*) FROM {cache.CACHE_TABLE}")
                cache_size = cursor.fetchone()[0]
                conn.close()
            except Exception:
                pass
            # Count endpoints
            endpoint_count = len(app.routes)
            info.update({
                "uptime_seconds": int(uptime),
                "cache_size": cache_size,
                "endpoint_count": endpoint_count
            })
        return JSONResponse(info)


    # Media gallery route
    @app.get("/ui/media")
    def media_gallery(request: Request):
        """Render the media gallery UI for authenticated users."""
        user = get_current_user(request)
        if not user:
            logger.debug("Unauthenticated access to media gallery, redirecting to login")
            return RedirectResponse(url=login_redirect_url("/ui/media"), status_code=302)

        all_media = [r for r in request.app.state.resources if r.resource_type == "media"]
        if getattr(user, "is_superuser", False):
            permitted_media = all_media
        else:
            with Session(engine) as db:
                checker = PermissionChecker(db)
                permitted_media = [
                    r for r in all_media
                    if checker.has_permission(user, r.relative_path.with_suffix("").as_posix(), "read")
                ]
        if not permitted_media and not getattr(user, "is_superuser", False):
            logger.warning("Permission denied for user %s: no accessible media resources", user.username)
            raise HTTPException(status_code=403, detail="No accessible media resources")

        media_items = []
        for r in permitted_media:
            media_items.append({
                "name": r.path.name,
                "relative_path": r.relative_path.as_posix(),
                "media_type": r.metadata.get("media_type", "unknown"),
                "file_size": r.metadata.get("file_size", 0),
                "duration": r.metadata.get("duration"),
                "bitrate": r.metadata.get("bitrate"),
                "title": r.metadata.get("title"),
                "artist": r.metadata.get("artist"),
                "album": r.metadata.get("album"),
                "genre": r.metadata.get("genre"),
                "thumbnail": r.metadata.get("thumbnail"),
            })
        accessible_resources = build_accessible_ui_links(request, user)
        context = {
            "media_items": media_items,
            "user": user,
            "ui_links": accessible_resources,
            "is_superuser": user and getattr(user, "is_superuser", False)
        }
        logger.debug("Rendering media gallery for user %s with %d items", user.username, len(media_items))
        return request.app.state.templates.TemplateResponse(request, "media_gallery.html", context)

    # Debug root route
    @app.get("/")
    def root(request: Request):
        """Handle root requests, rendering HTML landing page or JSON API response."""
        accept = request.headers.get("accept", "")
        if "text/html" in accept:
            # Render landing page
            user = get_current_user(request)
            accessible_resources = build_accessible_ui_links(request, user)

            # Add media gallery link only if the user can access at least one media file
            if any(link["type"] == "media" for link in accessible_resources):
                accessible_resources.append({"name": "Media Gallery", "url": "/ui/media", "type": "media"})

            context = {
                "user": user,
                "ui_links": accessible_resources,
                "is_superuser": user and getattr(user, "is_superuser", False)
            }
            logger.debug("Rendering HTML landing page for user %s", user.username if user else None)
            return request.app.state.templates.TemplateResponse(request, "landing.html", context)
        else:
            # JSON API response
            user = get_current_user(request)
            resources = _visible_resource_paths(request, user)
            logger.debug("Returning JSON API response with %d resources", len(resources))
            return {"resources": resources}

    # Exception handler for auth redirects
    @app.exception_handler(HTTPException)
    async def auth_exception_handler(request: Request, exc: HTTPException):
        """Handle HTTP exceptions, redirecting to login for 401 errors in HTML requests."""
        if exc.status_code == 401:
            accept = request.headers.get("accept", "")
            if "text/html" in accept:
                logger.debug("Redirecting unauthenticated request to login for %s", request.url)
                return RedirectResponse(url=login_redirect_url(request.url.path), status_code=302)

        logger.warning("HTTP exception %d: %s for request %s", exc.status_code, exc.detail, request.url)
        return JSONResponse(
            status_code=exc.status_code,
            content={"detail": exc.detail},
        )

    return app

Resource discovery and plugin contracts

Discover dataset resources in the root directory.

Parameters:

Name Type Description Default
root Path

The root directory to search.

required
config AdaptConfig

The Adapt configuration.

required

Returns:

Type Description
list[DatasetResource]

A list of discovered DatasetResource objects.

Source code in adapt/discovery.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def discover_resources(root: Path, config: AdaptConfig) -> list[DatasetResource]:
    """Discover dataset resources in the root directory.

    Args:
        root: The root directory to search.
        config: The Adapt configuration.

    Returns:
        A list of discovered DatasetResource objects.
    """
    logger.info(f"Discovering resources in {root}")
    resources: list[DatasetResource] = []
    supported = {ext for ext in config.plugin_registry}
    adapt_dir = root / ".adapt"

    for path in root.rglob("*"):
        if path.is_dir() or should_ignore(path):
            continue

        ext = path.suffix.lower()
        if ext not in supported:
            continue

        logger.debug(f"Processing file: {path}")
        plugin_cls = config.get_plugin_factory(ext)
        plugin: Plugin = plugin_cls()

        if not plugin.detect(path):
            logger.debug(
                "Plugin %s rejected file during detection: %s",
                plugin_cls.__name__,
                path,
            )
            continue

        loaded = plugin.load(path)
        if isinstance(loaded, ResourceDescriptor):
            descriptors = [loaded]
        else:
            descriptors = loaded

        for descriptor in descriptors:
            sub_namespace = descriptor.metadata.get("sub_namespace", "")
            suffix = f".{sub_namespace}" if sub_namespace else ""
            base_path = adapt_dir / path.relative_to(root)
            schema_path = base_path.with_suffix(f"{suffix}.schema.json")
            ui_path = base_path.with_suffix(f"{suffix}.index.html")
            options_path = base_path.with_suffix(f"{suffix}.options.json")

            descriptor.schema_path = schema_path
            descriptor.ui_path = ui_path
            descriptor.options_path = options_path

            # Options must be applied before companion files are generated, so a
            # schema derived from an overridden header row is written out correctly.
            descriptor.metadata["options"] = read_resource_options(options_path)
            plugin.apply_options(descriptor)

            plugin.generate_companion_files(descriptor)

            resource = DatasetResource(
                path=path,
                relative_path=path.relative_to(root),
                resource_type=descriptor.resource_type,
                schema_path=schema_path,
                ui_path=ui_path,
                options_path=options_path,
                plugin_name=plugin_cls.__name__,
                metadata=descriptor.metadata,
            )
            resources.append(resource)

    logger.info(f"Discovered {len(resources)} resources")
    return resources

Descriptor for a discovered resource.

Source code in adapt/plugins/base.py
33
34
35
36
37
38
39
40
41
@dataclass
class ResourceDescriptor:
    """Descriptor for a discovered resource."""
    path: Path
    resource_type: str
    schema_path: Path | None = None
    ui_path: Path | None = None
    options_path: Path | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

Bases: ABC

Abstract base class for all plugins.

Source code in adapt/plugins/base.py
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
class Plugin(ABC):
    """Abstract base class for all plugins."""

    @abstractmethod
    def detect(self, path: Path) -> bool:
        """Detect if this plugin can handle the given path."""
        ...

    @abstractmethod
    def load(self, path: Path) -> ResourceDescriptor | Sequence[ResourceDescriptor]:
        """Load resource descriptor(s) for the given path."""
        ...

    @abstractmethod
    def schema(self, resource: ResourceDescriptor) -> dict[str, Any]:
        """Return the schema for the resource."""
        return "", {}

    @abstractmethod
    def read(self, resource: ResourceDescriptor, request: Request) -> Any:
        """Read data/content for the resource."""
        ...

    @abstractmethod
    def write(self, resource: ResourceDescriptor, data: Any, request: Request, context: PluginContext) -> Any:
        """Write data/content for the resource."""
        ...

    def apply_options(self, descriptor: ResourceDescriptor) -> None:
        """Apply per-resource options to a descriptor after discovery has located them.

        Options come from the companion `.adapt/<name>[.<sub_namespace>].options.json`
        file and are already parsed into `descriptor.metadata["options"]`. This runs
        after `load()` because `load()` receives only a path and cannot know where the
        companion directory is; it runs before `generate_companion_files()` so that any
        derived schema reflects the options.

        The default does nothing. Plugins override this to honour the options they
        support.
        """
        logger.debug(f"No options to apply for resource: {descriptor.path}")

    def get_route_configs(self, descriptor: ResourceDescriptor) -> list[tuple[str, APIRouter]]:
        """Return list of (prefix, router) tuples for mounting routes."""
        logger.debug(f"Getting route configs for resource: {descriptor.path}")
        return []

    def index(self, resource: ResourceDescriptor) -> Iterable[SearchDocument]:
        """Yield documents for the full-text search index.

        The default returns nothing, so a resource is simply not searchable
        unless its plugin opts in.

        Note that the index is user-agnostic: `filter_for_user` is deliberately
        NOT applied here, because one index is shared by every user. Row-level
        security is enforced when a hit is followed to its API or UI route, and
        resource-level permissions are enforced when results are returned. If a
        plugin's rows are sensitive per-user beyond that, do not index them.
        """
        logger.debug(f"Resource not indexable: {resource.path}")
        return []

    def filter_for_user(self, resource: ResourceDescriptor, user: Any, rows: Iterable[Any]) -> Iterable[Any]:
        """Filter rows based on user context (Row-Level Security).

        Default implementation returns all rows. Override this in plugins to implement RLS.
        """
        logger.debug(f"Filtering rows for user on resource: {resource.path}")
        return rows

    def default_ui(self, descriptor: ResourceDescriptor) -> str:
        """Generate a default HTML UI for the resource."""
        logger.debug(f"Generating default UI for resource: {descriptor.path}")
        schema = self.schema(descriptor)
        columns = schema.get('columns', {})
        if isinstance(columns, dict):
            column_names = list(columns.keys())
        else:
            column_names = [col.get('name', 'Column') for col in columns] if columns else []
        columns_html = "".join(f"<th>{name}</th>" for name in column_names)

        template = """
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
</head>
<body>
    <h1>{{ title }}</h1>
    <table>
        <thead><tr>{columns_html}</tr></thead>
        <tbody>{{ table_rows }}</tbody>
    </table>
    <script>fetch('{{ api_url }}').then(/* populate rows */);</script>
</body>
</html>
""".strip()

        return template.format(columns_html=columns_html)

    def generate_companion_files(self, descriptor: ResourceDescriptor) -> None:
        """Generate companion files for the resource.

        Default implementation does nothing. Override in plugins that need companion files.
        """
        logger.debug(f"Generating companion files for resource: {descriptor.path}")
        pass

apply_options(descriptor)

Apply per-resource options to a descriptor after discovery has located them.

Options come from the companion .adapt/<name>[.<sub_namespace>].options.json file and are already parsed into descriptor.metadata["options"]. This runs after load() because load() receives only a path and cannot know where the companion directory is; it runs before generate_companion_files() so that any derived schema reflects the options.

The default does nothing. Plugins override this to honour the options they support.

Source code in adapt/plugins/base.py
81
82
83
84
85
86
87
88
89
90
91
92
93
def apply_options(self, descriptor: ResourceDescriptor) -> None:
    """Apply per-resource options to a descriptor after discovery has located them.

    Options come from the companion `.adapt/<name>[.<sub_namespace>].options.json`
    file and are already parsed into `descriptor.metadata["options"]`. This runs
    after `load()` because `load()` receives only a path and cannot know where the
    companion directory is; it runs before `generate_companion_files()` so that any
    derived schema reflects the options.

    The default does nothing. Plugins override this to honour the options they
    support.
    """
    logger.debug(f"No options to apply for resource: {descriptor.path}")

default_ui(descriptor)

Generate a default HTML UI for the resource.

Source code in adapt/plugins/base.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
    def default_ui(self, descriptor: ResourceDescriptor) -> str:
        """Generate a default HTML UI for the resource."""
        logger.debug(f"Generating default UI for resource: {descriptor.path}")
        schema = self.schema(descriptor)
        columns = schema.get('columns', {})
        if isinstance(columns, dict):
            column_names = list(columns.keys())
        else:
            column_names = [col.get('name', 'Column') for col in columns] if columns else []
        columns_html = "".join(f"<th>{name}</th>" for name in column_names)

        template = """
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
</head>
<body>
    <h1>{{ title }}</h1>
    <table>
        <thead><tr>{columns_html}</tr></thead>
        <tbody>{{ table_rows }}</tbody>
    </table>
    <script>fetch('{{ api_url }}').then(/* populate rows */);</script>
</body>
</html>
""".strip()

        return template.format(columns_html=columns_html)

detect(path) abstractmethod

Detect if this plugin can handle the given path.

Source code in adapt/plugins/base.py
56
57
58
59
@abstractmethod
def detect(self, path: Path) -> bool:
    """Detect if this plugin can handle the given path."""
    ...

filter_for_user(resource, user, rows)

Filter rows based on user context (Row-Level Security).

Default implementation returns all rows. Override this in plugins to implement RLS.

Source code in adapt/plugins/base.py
115
116
117
118
119
120
121
def filter_for_user(self, resource: ResourceDescriptor, user: Any, rows: Iterable[Any]) -> Iterable[Any]:
    """Filter rows based on user context (Row-Level Security).

    Default implementation returns all rows. Override this in plugins to implement RLS.
    """
    logger.debug(f"Filtering rows for user on resource: {resource.path}")
    return rows

generate_companion_files(descriptor)

Generate companion files for the resource.

Default implementation does nothing. Override in plugins that need companion files.

Source code in adapt/plugins/base.py
154
155
156
157
158
159
160
def generate_companion_files(self, descriptor: ResourceDescriptor) -> None:
    """Generate companion files for the resource.

    Default implementation does nothing. Override in plugins that need companion files.
    """
    logger.debug(f"Generating companion files for resource: {descriptor.path}")
    pass

get_route_configs(descriptor)

Return list of (prefix, router) tuples for mounting routes.

Source code in adapt/plugins/base.py
95
96
97
98
def get_route_configs(self, descriptor: ResourceDescriptor) -> list[tuple[str, APIRouter]]:
    """Return list of (prefix, router) tuples for mounting routes."""
    logger.debug(f"Getting route configs for resource: {descriptor.path}")
    return []

index(resource)

Yield documents for the full-text search index.

The default returns nothing, so a resource is simply not searchable unless its plugin opts in.

Note that the index is user-agnostic: filter_for_user is deliberately NOT applied here, because one index is shared by every user. Row-level security is enforced when a hit is followed to its API or UI route, and resource-level permissions are enforced when results are returned. If a plugin's rows are sensitive per-user beyond that, do not index them.

Source code in adapt/plugins/base.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def index(self, resource: ResourceDescriptor) -> Iterable[SearchDocument]:
    """Yield documents for the full-text search index.

    The default returns nothing, so a resource is simply not searchable
    unless its plugin opts in.

    Note that the index is user-agnostic: `filter_for_user` is deliberately
    NOT applied here, because one index is shared by every user. Row-level
    security is enforced when a hit is followed to its API or UI route, and
    resource-level permissions are enforced when results are returned. If a
    plugin's rows are sensitive per-user beyond that, do not index them.
    """
    logger.debug(f"Resource not indexable: {resource.path}")
    return []

load(path) abstractmethod

Load resource descriptor(s) for the given path.

Source code in adapt/plugins/base.py
61
62
63
64
@abstractmethod
def load(self, path: Path) -> ResourceDescriptor | Sequence[ResourceDescriptor]:
    """Load resource descriptor(s) for the given path."""
    ...

read(resource, request) abstractmethod

Read data/content for the resource.

Source code in adapt/plugins/base.py
71
72
73
74
@abstractmethod
def read(self, resource: ResourceDescriptor, request: Request) -> Any:
    """Read data/content for the resource."""
    ...

schema(resource) abstractmethod

Return the schema for the resource.

Source code in adapt/plugins/base.py
66
67
68
69
@abstractmethod
def schema(self, resource: ResourceDescriptor) -> dict[str, Any]:
    """Return the schema for the resource."""
    return "", {}

write(resource, data, request, context) abstractmethod

Write data/content for the resource.

Source code in adapt/plugins/base.py
76
77
78
79
@abstractmethod
def write(self, resource: ResourceDescriptor, data: Any, request: Request, context: PluginContext) -> Any:
    """Write data/content for the resource."""
    ...

MCP integration

Construct the FastMCP server and register its tools.

streamable_http_path is set to / because the outer app already mounts this server's ASGI app at /mcp (app.mount("/mcp", mcp_app)); leaving the SDK's own default of /mcp here would double up to /mcp/mcp.

Source code in adapt/mcp.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def build_mcp_server(config: AdaptConfig) -> FastMCP:
    """Construct the FastMCP server and register its tools.

    `streamable_http_path` is set to `/` because the outer app already mounts
    this server's ASGI app at `/mcp` (`app.mount("/mcp", mcp_app)`); leaving
    the SDK's own default of `/mcp` here would double up to `/mcp/mcp`.
    """
    mcp = FastMCP(
        name="adapt",
        instructions=(
            "Adapt exposes file-backed datasets, documents, and media as "
            "permission-filtered tools. Call list_resources first to see what "
            "you can read; search works across everything you're permitted to see."
        ),
        streamable_http_path="/",
    )

    @mcp.tool()
    async def list_resources(ctx: Context) -> dict:
        """List every resource namespace the caller may read, with its type."""
        request = ctx.request_context.request
        user = await _authenticated_user(ctx)
        registry: dict[str, ResourceRegistryEntry] = request.app.state.resource_registry

        if getattr(user, "is_superuser", False):
            readable = None
        else:
            with Session(request.app.state.db_engine) as db:
                readable = PermissionChecker(db).readable_resources(user)

        resources = [
            {"resource": namespace, "type": entry.resource.resource_type}
            for namespace, entry in registry.items()
            if readable is None or namespace in readable
        ]
        return {"resources": resources}

    @mcp.tool()
    async def get_schema(resource: str, ctx: Context) -> dict:
        """Return the schema for a dataset resource (columns and types)."""
        request = ctx.request_context.request
        user = await _authenticated_user(ctx)
        entry = _authorized_entry(request, user, resource, "read")
        return entry.plugin.schema(entry.descriptor)

    @mcp.tool()
    async def read_resource(
        resource: str,
        ctx: Context,
        limit: int | None = None,
        offset: int = 0,
        sort: SortParam = None,
        order: OrderParam = "asc",
        filter: FilterParam = None,
    ) -> Any:
        """Read a resource's content, permission-checked like the REST API.

        For dataset resources, `sort` is the column name and `order` must be
        `asc` or `desc`.
        """
        request = ctx.request_context.request
        user = await _authenticated_user(ctx)
        entry = _authorized_entry(request, user, resource, "read")
        request.state.user = user

        if entry.resource.resource_type in DATASET_TYPES:
            query_params = QueryParams(limit=limit, offset=offset, sort=sort, order=order, filter=filter)
            return entry.plugin.read(entry.descriptor, request, query_params)
        return entry.plugin.read(entry.descriptor, request)

    @mcp.tool()
    async def write_resource(
        resource: str,
        action: Literal["create", "update", "delete"],
        data: Any,
        ctx: Context,
    ) -> dict:
        """Create, update, or delete rows in a writable (dataset) resource."""
        request = ctx.request_context.request
        user = await _authenticated_user(ctx)
        entry = _authorized_entry(request, user, resource, "write")

        if request.app.state.config.readonly:
            raise ToolError("Server is in read-only mode")

        request.state.user = user
        context = PluginContext(
            engine=request.app.state.db_engine,
            root=request.app.state.config.root,
            readonly=request.app.state.config.readonly,
            lock_manager=request.app.state.lock_manager,
        )
        try:
            return entry.plugin.write(entry.descriptor, {"action": action, "data": data}, request, context)
        except NotImplementedError:
            raise ToolError(f"Resource type {entry.resource.resource_type!r} does not support write operations")
        except HTTPException as exc:
            raise ToolError(str(exc.detail))

    @mcp.tool()
    async def search(
        q: str,
        ctx: Context,
        limit: int = 20,
        offset: int = 0,
        resource_type: str | None = None,
    ) -> dict:
        """Full-text search across every resource the caller may read."""
        request = ctx.request_context.request
        user = await _authenticated_user(ctx)
        return _run_search(request, q, limit, offset, resource_type, user)

    return mcp