diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 261624757..c5803a38d 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -27,7 +27,7 @@ jobs: uses: ./.github/actions/setup-python-env - name: Build documentation - run: uv run zensical build --clean + run: make docs-build - name: Upload artifact uses: actions/upload-pages-artifact@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47f40afbe..fb9e64fee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,7 +122,7 @@ jobs: uses: ./.github/actions/setup-python-env - name: Build documentation - run: uv run zensical build --clean + run: make docs-build - name: Upload artifact uses: actions/upload-pages-artifact@v5 diff --git a/.gitignore b/.gitignore index 656fbfe30..555028b59 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,7 @@ venv.bak/ # mkdocs/zensical documentation /site +/docs/api/openapi.yaml e2e/bub/results/ # mypy diff --git a/Makefile b/Makefile index a5f39e7c5..7ec2c3b0c 100644 --- a/Makefile +++ b/Makefile @@ -148,13 +148,20 @@ publish: ## Publish a release to PyPI. .PHONY: build-and-publish build-and-publish: build publish ## Build and publish. +.PHONY: docs-build +docs-build: ## Build the documentation and publish the canonical OpenAPI contract. + @install -D -m 0644 openapi/powercontext.yaml docs/api/openapi.yaml + @trap 'rm -f docs/api/openapi.yaml' EXIT; uv run zensical build --clean -s + .PHONY: docs-test -docs-test: ## Test if documentation can be built without warnings or errors - @uv run zensical build -s +docs-test: docs-build ## Test if documentation can be built without warnings or errors + @test -f site/api/index.html + @cmp --silent openapi/powercontext.yaml site/api/openapi.yaml .PHONY: docs docs: ## Build and serve the documentation - @uv run zensical serve + @install -D -m 0644 openapi/powercontext.yaml docs/api/openapi.yaml + @trap 'rm -f docs/api/openapi.yaml' EXIT; uv run zensical serve $(ARGS) .PHONY: help help: diff --git a/docs/api/index.html b/docs/api/index.html new file mode 100644 index 000000000..2208ec609 --- /dev/null +++ b/docs/api/index.html @@ -0,0 +1,39 @@ + + + + + PowerContext HTTP API Reference + + + + +
+ + + + + diff --git a/docs/en/docs/reference/interfaces.md b/docs/en/docs/reference/interfaces.md index 661268c4a..495c939de 100644 --- a/docs/en/docs/reference/interfaces.md +++ b/docs/en/docs/reference/interfaces.md @@ -312,10 +312,12 @@ want the supplied SQLite or OceanBase-backed implementation in the same process. ## HTTP and MCP See [HTTP API](http-api.md) for authentication, curl examples, operation groups, errors, and the complete OpenAPI -contract. The Server publishes its OpenAPI document at `/openapi.json`, readiness at `/health/ready`, capabilities at -`/v1/capabilities`, and Streamable HTTP MCP at `/mcp` by default. HTTP is the complete application contract. MCP is a -curated agent-facing projection of Memory and Candidate Review operations. The five Candidate Review operations use -the same validation, `expected_version` concurrency checks, and approval transaction over HTTP and MCP. +contract. The Server publishes a Scalar API reference at `/docs`, its OpenAPI document at `/openapi.json`, readiness at +`/health/ready`, capabilities at `/v1/capabilities`, and Streamable HTTP MCP at `/mcp` by default. The Scalar reference +remains public when bearer authentication is enabled, but the operations it describes retain their normal authentication +requirements. HTTP is the complete application contract. MCP is a curated agent-facing projection of Memory and +Candidate Review operations. The five Candidate Review operations use the same validation, `expected_version` +concurrency checks, and approval transaction over HTTP and MCP. Readiness is `ready` with HTTP 200 when all checks pass, `degraded` with HTTP 200 when only configured inference checks fail, and `not_ready` with HTTP 503 when the Runtime or database fails. Dependency checks use `ready`, `unavailable`, `timeout`, or `misconfigured`; an intentionally unbound Runtime reports `not_ready` for the `runtime` check. diff --git a/docs/zh/docs/reference/interfaces.md b/docs/zh/docs/reference/interfaces.md index 3042aac53..7455389ee 100644 --- a/docs/zh/docs/reference/interfaces.md +++ b/docs/zh/docs/reference/interfaces.md @@ -287,12 +287,13 @@ Discovery 不进入 Review。显式调用 `import_external_skill` 并提供精 ## HTTP 和 MCP -鉴权、curl 示例、操作分组、错误格式和完整 OpenAPI 契约见 [HTTP API](http-api.md)。Server 在 -`/openapi.json` 提供 OpenAPI 文档,在 `/health/ready` 提供就绪检查,在 `/v1/capabilities` -提供能力信息,并默认在 `/mcp` 提供 Streamable HTTP MCP。HTTP 是完整应用契约,MCP 是面向 Agent 的 -Memory 与 Candidate Review operation 子集。五个 Candidate Review operation 通过 HTTP 和 MCP 使用相同的 -validation、`expected_version` 并发校验和 approval transaction。Experience/Skill generation、exact read、 -external Registry operation 和低阶 proposal operation 仍只通过 HTTP 提供。 +鉴权、curl 示例、操作分组、错误格式和完整 OpenAPI 契约见 [HTTP API](http-api.md)。Server 在 `/docs` 提供 +Scalar API reference,在 `/openapi.json` 提供 OpenAPI 文档,在 `/health/ready` 提供就绪检查,在 +`/v1/capabilities` 提供能力信息,并默认在 `/mcp` 提供 Streamable HTTP MCP。启用 Bearer authentication 后, +Scalar reference 仍可公开访问,但其中描述的 operation 继续遵守各自的认证要求。HTTP 是完整应用契约,MCP 是 +面向 Agent 的 Memory 与 Candidate Review operation 子集。五个 Candidate Review operation 通过 HTTP 和 MCP +使用相同的 validation、`expected_version` 并发校验和 approval transaction。Experience/Skill generation、 +exact read、external Registry operation 和低阶 proposal operation 仍只通过 HTTP 提供。 所有检查通过时 readiness 为 HTTP 200 的 `ready`;只有已配置的推理检查失败时为 HTTP 200 的 `degraded`; Runtime 或数据库失败时为 HTTP 503 的 `not_ready`。依赖检查使用 `ready`、`unavailable`、`timeout` 或 `misconfigured`;有意不绑定 Runtime 时,`runtime` 检查使用 `not_ready`。 diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 3a361263c..435a3197c 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -1649,6 +1649,7 @@ requires-dist = [ { name = "pyobvector", marker = "extra == 'seekdb'", specifier = ">=0.2.28,<0.3" }, { name = "pyobvector", marker = "extra == 'server'", specifier = ">=0.2.28,<0.3" }, { name = "rfc8785", specifier = ">=0.1.4,<1" }, + { name = "scalar-fastapi", marker = "extra == 'server'", specifier = ">=1.8.2,<2" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'builtin'", specifier = ">=2,<3" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'seekdb'", specifier = ">=2,<3" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2,<3" }, @@ -1697,7 +1698,7 @@ dependencies = [ requires-dist = [ { name = "bub", specifier = ">=0.4.0,<0.5.0" }, { name = "httpx", specifier = ">=0.28,<1" }, - { name = "powercontext", extras = ["client"], specifier = ">=0.0.3" }, + { name = "powercontext", extras = ["client"] }, { name = "pydantic-settings", specifier = ">=2.7,<3" }, ] diff --git a/integrations/bub/pyproject.toml b/integrations/bub/pyproject.toml index 4a3f36128..8063d100f 100644 --- a/integrations/bub/pyproject.toml +++ b/integrations/bub/pyproject.toml @@ -20,7 +20,7 @@ requires-python = ">=3.12,<4.0" dependencies = [ "bub>=0.4.0,<0.5.0", "httpx>=0.28,<1", - "powercontext[client]>=0.0.3", + "powercontext[client]", "pydantic-settings>=2.7,<3", ] diff --git a/pyproject.toml b/pyproject.toml index 81bcb5874..d84445595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ server = [ "powercontext[builtin]", "prometheus-client>=0.21,<1", "pydantic-settings>=2.7,<3", + "scalar-fastapi>=1.8.2,<2", "uvicorn>=0.34,<1", ] tracing-otlp = [ diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 8cfd96edd..ebe042b4f 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -32,6 +32,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from opentelemetry.trace import SpanKind +from scalar_fastapi import AgentScalarConfig, get_scalar_api_reference from starlette.middleware import Middleware from starlette.middleware.base import RequestResponseEndpoint from starlette.types import Lifespan @@ -390,6 +391,7 @@ from powercontext.server.metrics import ServerMetrics from powercontext.server.tracing import ServerTracing +_SCALAR_JS_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.66.1" REQUEST_ID_HEADER = "X-PowerContext-Request-ID" REPORT_SELECTION_DIGEST_HEADER = "X-PowerContext-Selection-Digest" REPORT_DIGEST_HEADER = "X-PowerContext-Report-Digest" @@ -571,6 +573,8 @@ def create_app( title=API_TITLE, version=API_VERSION, description=API_DESCRIPTION, + docs_url=None, + redoc_url=None, lifespan=lifespan, middleware=list(middleware), ) @@ -686,6 +690,12 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, APPROVE_ARTIFACT_CANDIDATE, approve_artifact_candidate) _add_route(app, REJECT_ARTIFACT_CANDIDATE, reject_artifact_candidate) _add_route(app, REVISE_ARTIFACT_CANDIDATE, revise_artifact_candidate) + app.add_api_route( + "/docs", + scalar_api_reference, + include_in_schema=False, + methods=["GET"], + ) def canonical_openapi() -> dict[str, Any]: if app.openapi_schema is None: @@ -701,6 +711,21 @@ def canonical_openapi() -> dict[str, Any]: return app +async def scalar_api_reference(request: Request) -> Response: + """Render the runtime OpenAPI contract with Scalar.""" + + return get_scalar_api_reference( + content=request.app.openapi(), + title=f"{API_TITLE} Reference", + scalar_js_url=_SCALAR_JS_URL, + scalar_favicon_url="data:,", + with_default_fonts=False, + show_developer_tools="never", + telemetry=False, + agent=AgentScalarConfig(disabled=True), + ) + + async def get_liveness() -> HealthResponse: return HealthResponse(status="ok") diff --git a/src/powercontext/server/middleware.py b/src/powercontext/server/middleware.py index abd9891cf..605140f04 100644 --- a/src/powercontext/server/middleware.py +++ b/src/powercontext/server/middleware.py @@ -25,7 +25,7 @@ from powercontext.http import ErrorDetail, ErrorResponse from powercontext.server.context import is_internal_bridge -_PUBLIC_PATHS = frozenset({"/", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"}) +_PUBLIC_PATHS = frozenset({"/", "/docs", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"}) _PUBLIC_PATH_PREFIXES = ("/static/",) diff --git a/tests/test_server.py b/tests/test_server.py index 9c541f11b..ab62a3473 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -276,6 +276,20 @@ def test_liveness_adds_a_server_owned_request_id() -> None: assert "X-Request-ID" not in response.headers +def test_scalar_reference_embeds_the_canonical_openapi_contract() -> None: + client = TestClient(create_app()) + response = client.get("/docs") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + assert "PowerContext API Reference" in response.text + assert '"openapi": "3.0.3"' in response.text + assert '"/v1/context/prepare"' in response.text + assert "@scalar/api-reference@1.66.1" in response.text + assert "proxyUrl" not in response.text + assert client.get("/scalar").status_code == 404 + + def test_server_factory_optionally_requires_bearer_authentication() -> None: app = create_server_app( settings=ServerSettings( @@ -291,6 +305,7 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: protected_metrics = client.get("/metrics") accepted_metrics = client.get("/metrics", headers={"Authorization": "Bearer server-secret"}) liveness = client.get("/health/live") + scalar_reference = client.get("/docs") assert missing.status_code == 401 assert missing.headers["WWW-Authenticate"] == "Bearer" @@ -307,6 +322,7 @@ def test_server_factory_optionally_requires_bearer_authentication() -> None: assert protected_metrics.status_code == 401 assert accepted_metrics.status_code == 200 assert liveness.status_code == 200 + assert scalar_reference.status_code == 200 def test_readiness_reports_unavailable_bindings() -> None: diff --git a/uv.lock b/uv.lock index 8191a6a45..01d4f1495 100644 --- a/uv.lock +++ b/uv.lock @@ -2131,6 +2131,7 @@ server = [ { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, { name = "pydantic-settings" }, { name = "pyobvector" }, + { name = "scalar-fastapi" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlite-vec" }, { name = "uvicorn" }, @@ -2196,6 +2197,7 @@ requires-dist = [ { name = "pyobvector", marker = "extra == 'seekdb'", specifier = ">=0.2.28,<0.3" }, { name = "pyobvector", marker = "extra == 'server'", specifier = ">=0.2.28,<0.3" }, { name = "rfc8785", specifier = ">=0.1.4,<1" }, + { name = "scalar-fastapi", marker = "extra == 'server'", specifier = ">=1.8.2,<2" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'builtin'", specifier = ">=2,<3" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'seekdb'", specifier = ">=2,<3" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2,<3" }, @@ -3173,6 +3175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "scalar-fastapi" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/81/c0c70776a3d7d371ee06d38d26a8d361c97439d46f79acb6d67cf6c760ad/scalar_fastapi-1.8.2.tar.gz", hash = "sha256:0de09b8c63f78c1052792faa200d740b2ccaeeb88ac54e7ea633ac4edc6fde82", size = 8371, upload-time = "2026-04-09T22:41:24.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/b1/b9a482620479b2801a4dfa5c4c10b4cc280097e894114e0298f21a0fca55/scalar_fastapi-1.8.2-py3-none-any.whl", hash = "sha256:d96e2c8b3676491eaebb4ec8d9f4de77adb2374d86f87d321546fa6f084e8cb8", size = 7677, upload-time = "2026-04-09T22:41:23.209Z" }, +] + [[package]] name = "secretstorage" version = "3.5.0" diff --git a/zensical.toml b/zensical.toml index 3365122da..608c1a12e 100644 --- a/zensical.toml +++ b/zensical.toml @@ -46,6 +46,7 @@ nav = [ { "Interfaces" = "en/docs/reference/interfaces.md" }, { "HTTP API" = "en/docs/reference/http-api.md" }, { "Configuration" = "en/docs/reference/configuration.md" }, + { "HTTP API Reference" = "api/index.html" }, ] }, ] }, { "Development" = [ @@ -127,6 +128,7 @@ nav = [ { "接口" = "zh/docs/reference/interfaces.md" }, { "HTTP API" = "zh/docs/reference/http-api.md" }, { "配置" = "zh/docs/reference/configuration.md" }, + { "HTTP API 参考" = "api/index.html" }, ] }, ] }, { "开发" = [