From 32690d00e08cc872b669e65f383a93bfb4bda102 Mon Sep 17 00:00:00 2001 From: Nick Seal <32712898+blisspixel@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:01:34 -0700 Subject: [PATCH 1/2] Add optional cloud framework and finalize MCP v2 readiness --- .dockerignore | 19 + .github/workflows/ci.yml | 53 +- .github/workflows/release.yml | 4 +- .gitignore | 10 + README.md | 29 +- ROADMAP.md | 66 +- deploy/README.md | 42 ++ deploy/container/Dockerfile | 40 ++ deploy/container/README.md | 83 +++ deploy/gcp-cloud-run/.terraform.lock.hcl | 22 + deploy/gcp-cloud-run/README.md | 173 +++++ deploy/gcp-cloud-run/main.tf | 169 +++++ deploy/gcp-cloud-run/outputs.tf | 19 + deploy/gcp-cloud-run/variables.tf | 140 ++++ deploy/gcp-cloud-run/versions.tf | 15 + docs/README.md | 1 + docs/adr/0009-mcp-2026-readiness.md | 13 +- docs/engineering-refinement-plan.md | 31 +- docs/mcp-2026-07-28-readiness.md | 78 +-- docs/mcp.md | 21 +- docs/optional-cloud-deployment-plan.md | 661 ++++++++++++++++++ docs/roadmap.md | 136 +++- docs/strategic-gap-audit.md | 27 +- scripts/check_mcp_compatibility.py | 2 +- src/recon_tool/remote_server.py | 420 +++++++++++ .../test_documentation_semantic_contracts.py | 56 +- tests/test_remote_server.py | 468 +++++++++++++ tests/test_strategic_gap_audit.py | 7 +- 28 files changed, 2658 insertions(+), 147 deletions(-) create mode 100644 .dockerignore create mode 100644 deploy/README.md create mode 100644 deploy/container/Dockerfile create mode 100644 deploy/container/README.md create mode 100644 deploy/gcp-cloud-run/.terraform.lock.hcl create mode 100644 deploy/gcp-cloud-run/README.md create mode 100644 deploy/gcp-cloud-run/main.tf create mode 100644 deploy/gcp-cloud-run/outputs.tf create mode 100644 deploy/gcp-cloud-run/variables.tf create mode 100644 deploy/gcp-cloud-run/versions.tf create mode 100644 docs/optional-cloud-deployment-plan.md create mode 100644 src/recon_tool/remote_server.py create mode 100644 tests/test_remote_server.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c7edd32d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.agent +.git +.github +.hypothesis +.pytest_cache +.ruff_cache +.terraform +.venv +agents +build +dist +docs +htmlcov +logs +tests +validation +*.egg-info +*.pyc +__pycache__ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c6c3a75..c212aff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,53 @@ jobs: - name: Lint run: uv run ruff check --no-cache . + optional-cloud-draft-artifacts: + # These checks keep the optional draft internally consistent. They do not + # establish that it has been deployed or validated on a real provider. + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Set up Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: "1.15.8" + terraform_wrapper: false + + - name: Check draft Cloud Run Terraform syntax + run: | + terraform fmt -check -recursive deploy/gcp-cloud-run + terraform -chdir=deploy/gcp-cloud-run init -backend=false -input=false -lockfile=readonly + terraform -chdir=deploy/gcp-cloud-run validate + + - name: Build draft remote container + run: docker build --file deploy/container/Dockerfile --tag recon-remote:ci . + + - name: Smoke-test draft remote container locally + shell: bash + run: | + set -euo pipefail + token="$(openssl rand -base64 48 | tr -d '\n')" + docker run --detach --name recon-remote-ci --publish 18080:8080 \ + --env RECON_REMOTE_BEARER_TOKEN="$token" recon-remote:ci + trap 'docker rm --force recon-remote-ci >/dev/null 2>&1 || true' EXIT + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:18080/health >/dev/null; then + break + fi + if [ "$attempt" -eq 30 ]; then + docker logs recon-remote-ci + exit 1 + fi + sleep 1 + done + test "$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --request POST --header 'Content-Type: application/json' \ + --data '{}' http://127.0.0.1:18080/mcp)" = "401" + typecheck: runs-on: ubuntu-latest timeout-minutes: 15 @@ -81,15 +128,15 @@ jobs: run: uv run pyright mcp-compatibility: - # Exercise the supported stable SDK and the exact v2 release candidate in + # Exercise the supported stable v1 SDK and the exact stable v2 SDK in # isolated environments. Production remains constrained to stable v1; the - # candidate pin is a compatibility proof, not a published dependency. + # v2 pin is a compatibility proof, not a published dependency. runs-on: ubuntu-latest timeout-minutes: 20 strategy: fail-fast: false matrix: - mcp-version: ["1.28.1", "2.0.0b1"] + mcp-version: ["1.28.1", "2.0.0"] env: UV_PYTHON: "3.11" steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e72d23d2..2cf52245 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,9 +129,9 @@ jobs: run: >- uv run python scripts/check_mcp_compatibility.py --sdk-version 1.28.1 - --sdk-version 2.0.0b1 + --sdk-version 2.0.0 --require-compatible 1.28.1 - --require-compatible 2.0.0b1 + --require-compatible 2.0.0 - name: Run complete local quality gate # A manually pushed tag cannot bypass the deterministic gate that diff --git a/.gitignore b/.gitignore index 9bc66df0..a36c09c5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,16 @@ build/ *.whl *.tar.gz +# Optional infrastructure working state +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +*.tfvars.json +crash.log +crash.*.log + # Test / Coverage .pytest_cache/ .hypothesis/ diff --git a/README.md b/README.md index c4b867cd..0debdba5 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,10 @@ of one organization, owner, account, or deployed product. It uses no credentials, no API keys, no paid feeds, and no active scanning. It ships as a local Python package with a CLI, versioned JSON output, and a stdio MCP server. -It is not a hosted service, scheduler, vulnerability scanner, company research -tool, or firmographic database. +The project does not operate a hosted service. Optional draft guidance is +available for operator-owned remote access, but it is never required for local +use. recon is not a scheduler, vulnerability scanner, company research tool, +or firmographic database. > **Defensive use only.** Use recon for legitimate posture review, IT > architecture review, vendor diligence, and defensive hardening. See @@ -292,6 +294,17 @@ guidance, and troubleshooting live in Per-client scaffolds live in [agents/](https://github.com/blisspixel/recon/tree/main/agents). +## Optional Cloud Access + +Local execution remains the default. For teams that want shared remote access, +the repository includes a draft authenticated container and Cloud Run Terraform +starting point. The framework is intended to be directionally useful, not a +validated production deployment. Operators own deployment, identity, data +handling, cost, and operations. + +- [Optional cloud architecture and platform plan](https://github.com/blisspixel/recon/blob/main/docs/optional-cloud-deployment-plan.md) +- [Draft deployment framework](https://github.com/blisspixel/recon/tree/main/deploy) + ## Limitations The public channel has a ceiling: @@ -315,6 +328,8 @@ before committing any validation artifact. - [docs/README.md](https://github.com/blisspixel/recon/blob/main/docs/README.md): complete docs index. - [docs/roadmap.md](https://github.com/blisspixel/recon/blob/main/docs/roadmap.md): current plan, invariants, and scope boundaries. +- [docs/optional-cloud-deployment-plan.md](https://github.com/blisspixel/recon/blob/main/docs/optional-cloud-deployment-plan.md): optional cloud + architecture, maturity, and validation gates. - [docs/structural-maintainability.md](https://github.com/blisspixel/recon/blob/main/docs/structural-maintainability.md): measured source, test, compatibility, and facade cleanup plan. - [docs/external-writeup-plan.md](https://github.com/blisspixel/recon/blob/main/docs/external-writeup-plan.md): active @@ -333,13 +348,17 @@ priorities are: 1. Make every default claim traceable to evidence and remove product-use, cloud-type, or security-maturity conclusions that public metadata cannot support. -2. Keep the exact MCP v1.28.1 and v2.0.0b1 compatibility matrix green, then - repeat the full gate against the final 2026-07-28 specification and stable - v2 SDK before changing the production dependency. +2. Keep the exact MCP v1.28.1 and v2.0.0 compatibility matrix green. The + stable-v2 compatibility gate passed on 2026-07-28; changing the production + dependency remains a separate, deliberate release decision. 3. Establish an aggregate-safe quality baseline for claim precision, abstention, provenance, catalog coverage, degradation, latency, CT value, and agent context cost before expanding inference or graph machinery. +A fourth, explicitly lower-priority track covers the optional cloud framework. +It does not change the local default and remains subject to the maturity and +validation gates in the linked plan. + Catalog coverage work uses deduplicated private rounds across rank, region, and domain-class strata. Real target names and per-domain records stay in ignored local validation workspaces. GitHub receives only generic provider patterns, diff --git a/ROADMAP.md b/ROADMAP.md index 55046b93..9f90fab1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,6 +13,12 @@ contract, the local stdio MCP server, bounded public-metadata collectors, generated-artifact guards, the validation gates, and a release path with reproducible builds, provenance, SBOM, and cross-channel byte parity. +An optional authenticated remote container and draft Google Cloud Run IaC +framework now exist as low-priority accessibility and scale polish. This draft +is intended to be directionally useful, not a validated production deployment. +It does not change the local default, and the project does not operate a hosted +endpoint. + Release verification binds every published artifact to its exact tag, workflow, signer, and commit digest, and requires SBOM provenance. One digest-bound v2.6.3 historical exception preserves that release's published @@ -23,8 +29,8 @@ A complete baseline is not a finished product. Three things remain unproven, and the plan below is about exactly those three: - Not every default claim has been traced to the evidence that supports it. -- The MCP protocol recon speaks is about to change, and the final gate has not - run. +- Stable MCP v2 compatibility is now characterized, but production adoption + remains a separate release decision. - Nothing measures whether probabilistic fusion, certificate-transparency enrichment, the fingerprint catalog, or the broad agent surface improves an operator outcome over deterministic evidence plus explicit abstention. @@ -64,27 +70,24 @@ ownership, control, or current-use claims; missing metadata stays unknown; and explanation output reports provenance completeness rather than asserting a complete path it does not have. -### 2. Characterize the final MCP protocol before adopting it +### 2. Keep final MCP v2 compatibility green before adopting it -**Why now, despite ranking second:** this is the only track with an external -clock. The Model Context Protocol 2026-07-28 specification is a breaking -protocol release, and the official Python SDK moves on its own schedule -regardless of recon. Every other track moves at the maintainer's pace. This one -does not, and deferring it makes it harder rather than cheaper. The work itself -is bounded, which is why it can be scheduled without stalling track 1. +**Why second:** the Model Context Protocol 2026-07-28 specification is a +breaking protocol release, and the official Python SDK moves on its own +schedule regardless of recon. The compatibility work is bounded and remains +blocking in CI without displacing track 1. -**State:** the exact `1.28.1` and `2.0.0b1` matrix passed on 2026-07-13, and CI -keeps both pins blocking. The SDK published `2.0.0b2` on 2026-07-14, one day -after that run, so the characterized candidate is one release behind the -current beta. +**State:** the exact `1.28.1` and stable `2.0.0` matrix passed on 2026-07-28, +and CI keeps both pins blocking. The same registration and domain logic passes +legacy initialization and final stateless `server/discover` behavior. -**Closed when:** the dated matrix covers the current candidate and then the -final specification with the stable v2 SDK; tool and resource order stays +**Closed when:** the stable matrix stays green; tool and resource order stays deterministic; declared output schemas and structured results conform on both -generations; and the local stdio workflow is intact. Production stays on -`mcp>=1.28.1,<2` until that full gate passes. Remote HTTP, OAuth, Roots, -Sampling, Apps, and Tasks are not adopted along the way without a named product -need and a separate architecture review. +generations; and the local stdio workflow remains intact. Production stays on +`mcp>=1.28.1,<2` until a separate adoption review changes it. The named +optional remote-access need and its separate architecture review now live in +[the cloud deployment plan](docs/optional-cloud-deployment-plan.md); that work +does not imply production v2 adoption, OAuth, Roots, Sampling, Apps, or Tasks. ### 3. Freeze a product-quality baseline, then promote or retire @@ -101,6 +104,28 @@ whether advanced fusion stays in the primary path or becomes an explicitly advanced diagnostic. An inconclusive or negative result is a valid outcome and is not reinterpreted into a promotion. +### 4. Optional operator-hosted access and scale-out + +**Why fourth:** making recon easier to reach from different AI systems is useful +polish for some operators, but it does not outrank output truthfulness, +protocol compatibility, or evidence that the product improves an operator +decision. + +**State:** draft shared runtime, non-root OCI container, Cloud Run Terraform, +authentication boundaries, and CI structural checks exist. They pass local +artifact checks but are not yet provider-validated or production-ready. Local +CLI and stdio MCP remain the complete default. AWS AgentCore, Azure Container +Apps, Cloudflare, Kubernetes, and per-user OAuth are research directions with +explicit stop rules rather than unvalidated placeholder IaC. + +**Closed when:** one external operator has validated the chosen reference with +a real MCP client, bounded load and cost evidence, credential rotation, log +retention, and image rollback. Expansion to another provider requires named +demand and that provider's validation context. + +Full architecture, research, provider choices, and sequencing: +[docs/optional-cloud-deployment-plan.md](docs/optional-cloud-deployment-plan.md). + ## What Is Deliberately Not Next Each of these is real work that is blocked on purpose, not forgotten. @@ -110,6 +135,8 @@ Each of these is real work that is blocked on purpose, not forgotten. | Broad catalog growth | The independent rank, regional, vendor-seed, and drift rounds. A repeated list is a drift round, not new coverage. | | More graph or probabilistic machinery | Measured benefit to a named user outcome, from track 3. | | A core-versus-advanced MCP tool profile | A representative client proving material context benefit. Payload size alone is not the trigger. | +| More optional cloud provider IaC | A named operator, provider-specific identity and region context, and the acceptance gate in the optional cloud plan. | +| A project-operated public or multi-tenant service | A separate product, governance, privacy, abuse, support, and funding decision. The current plan provides operator-owned references only. | | Promoting generated discovery artifacts to a stable contract | A named external consumer, under [ADR-0007](docs/adr/0007-surface-inventory-discovery-context.md). | | Native acceleration in Rust, Go, or Mojo | The evidence gates in [ADR-0010](docs/adr/0010-evidence-gated-native-acceleration.md), measured on a real stage rather than a microbenchmark. | | Dimensioned email posture scoring | An ADR plus the RFC 9989 completion audit, keeping the current stable field as a compatibility view. | @@ -184,6 +211,7 @@ and the most recent completed historical local submission-freeze proof is | How the next tracks get implemented | [docs/engineering-refinement-plan.md](docs/engineering-refinement-plan.md) | | Source, test, and facade cleanup | [docs/structural-maintainability.md](docs/structural-maintainability.md) | | MCP timeline, gate, and rollback criteria | [docs/mcp-2026-07-28-readiness.md](docs/mcp-2026-07-28-readiness.md) and [ADR-0009](docs/adr/0009-mcp-2026-readiness.md) | +| Optional remote MCP, cloud hosting, authentication, and scale-out | [docs/optional-cloud-deployment-plan.md](docs/optional-cloud-deployment-plan.md) | | Catalog rounds and the promotion gate | [docs/catalog-strategy.md](docs/catalog-strategy.md) | | The publication freeze gate | [docs/submission-freeze-checklist.md](docs/submission-freeze-checklist.md) | | Earlier plans and superseded framing | [docs/roadmap-history.md](docs/roadmap-history.md) | diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..d9b1ba6b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,42 @@ +# Draft Optional Deployment Framework + +These files are optional operator-owned deployment references. They are not +required to install or use recon, and the recon project does not deploy or +operate a hosted service. The default remains the local CLI and local stdio MCP +server. + +Status: draft framework intended to be directionally useful. These files pass +repository syntax, build, and local protocol checks, but they are not yet +provider-validated or production-ready. They are starting points for evaluation +in an operator-owned non-production account, not a support promise or a claim +that the documented cloud behavior has been exercised end to end. + +Available draft artifacts: + +| Path | Purpose | Status | +|---|---|---| +| [container](container/README.md) | Authenticated, stateless remote MCP container | Draft, locally built and smoke-tested | +| [gcp-cloud-run](gcp-cloud-run/README.md) | Scale-to-zero Cloud Run service with Terraform | Draft, syntax-checked but not applied to Cloud Run | + +AWS, Azure, Cloudflare, Kubernetes, Anthropic, OpenAI, and other client paths +are evaluated in the +[optional cloud deployment plan](../docs/optional-cloud-deployment-plan.md). +They are not represented here by placeholder infrastructure that has not met +its platform-specific validation gate. + +Every operator owns the cloud account, identity provider, bill, logs, policy, +target allowlist if one is needed, upgrades, and incident response for their +deployment. No deployment sends telemetry or usage data to the recon project. + +## How to evaluate the draft + +1. Start with the cross-platform plan and choose the client, compute provider, + identity boundary, region, and expected traffic separately. +2. Use a dedicated non-production account or project with a budget and a hard + resource ceiling. +3. Build from a reviewed revision, pin the image digest and secret version, and + inspect the complete IaC plan before applying it. +4. Run the provider promotion checklist, including negative authentication, + load and cost bounds, log review, secret rotation, rollback, and deletion. +5. Call it provider-validated only after recording that evidence. Until then, + describe it as a draft framework and keep local recon as the default. diff --git a/deploy/container/Dockerfile b/deploy/container/Dockerfile new file mode 100644 index 00000000..f539f41d --- /dev/null +++ b/deploy/container/Dockerfile @@ -0,0 +1,40 @@ +ARG PYTHON_IMAGE=python:3.11.15-slim-bookworm@sha256:b18992999dbe963a45a8a4da40ac2b1975be1a776d939d098c647482bcad5cba + +FROM ${PYTHON_IMAGE} AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.11.17@sha256:03bdc89bb9798628846e60c3a9ad19006c8c3c724ccd2985a33145c039a0577b /uv /uvx /bin/ + +WORKDIR /app +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +COPY pyproject.toml uv.lock README.md LICENSE ./ +COPY src ./src + +RUN uv sync --frozen --no-dev --no-editable + +FROM ${PYTHON_IMAGE} AS runtime + +LABEL org.opencontainers.image.source="https://github.com/blisspixel/recon" \ + org.opencontainers.image.licenses="Apache-2.0" + +RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin recon \ + && mkdir -p /home/recon/.recon \ + && chown -R 10001:10001 /home/recon + +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv + +ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + RECON_REMOTE_HOST=0.0.0.0 \ + RECON_REMOTE_PORT=8080 + +USER 10001:10001 +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ.get('RECON_REMOTE_PORT', '8080') + '/health', timeout=2).read()" + +ENTRYPOINT ["python", "-m", "recon_tool.remote_server"] diff --git a/deploy/container/README.md b/deploy/container/README.md new file mode 100644 index 00000000..42e1ee60 --- /dev/null +++ b/deploy/container/README.md @@ -0,0 +1,83 @@ +# Draft Optional Remote MCP Container + +This container is an opt-in adapter for operators who want authenticated remote +access or bounded scale-out. It does not replace the local CLI or local stdio +MCP server, and the project does not publish or operate a hosted endpoint. + +Status: draft artifact. The image has been built and exercised through a local +authenticated MCP smoke test, but it has not yet been promoted through a real +cloud-provider ingress, identity, autoscaling, cost, logging, rotation, and +rollback gate. Local success is useful evidence about the container contract, +not a production-readiness claim. + +## Runtime contract + +- Streamable HTTP MCP endpoint: `/mcp` +- Unauthenticated process health endpoint: `/health` +- Stateless JSON responses +- Explicitly read-only tools only +- Default bind: `0.0.0.0:8080` +- Non-root runtime user +- One MiB request-body limit by default +- Browser requests rejected unless their exact Origin is allowed +- Uvicorn access log disabled so bearer credentials are not copied into it + +The image uses a digest-pinned Python 3.11.15 base and digest-pinned uv 0.11.17 +binary. Dependencies are installed from the checked-in `uv.lock`. Build a new +image for each recon revision and deploy its immutable digest. + +## Local container smoke test + +Build from the repository root: + +```powershell +docker build --platform linux/amd64 --file deploy/container/Dockerfile --tag recon-remote:local . +``` + +Generate a random token in the current PowerShell process without putting it +on the command line: + +```powershell +$tokenBytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(48) +$env:RECON_REMOTE_BEARER_TOKEN = [Convert]::ToBase64String($tokenBytes) +docker run --rm --publish 8080:8080 --env RECON_REMOTE_BEARER_TOKEN recon-remote:local +``` + +In another terminal, verify `http://127.0.0.1:8080/health`, then connect an MCP +Inspector or client to `http://127.0.0.1:8080/mcp` with an `Authorization: +Bearer ` header. A request without that header must return 401. + +## Configuration + +| Variable | Default | Contract | +|---|---|---| +| `RECON_REMOTE_AUTH_MODE` | `static-bearer` | `static-bearer` or `trusted-platform` | +| `RECON_REMOTE_BEARER_TOKEN` | none | Required in static mode; at least 32 ASCII bytes with no whitespace | +| `RECON_REMOTE_HOST` | `0.0.0.0` | Process bind host | +| `RECON_REMOTE_PORT` | `8080` | Process port | +| `RECON_REMOTE_MAX_REQUEST_BYTES` | `1048576` | Request cap from 1 KiB through 16 MiB | +| `RECON_REMOTE_ALLOWED_HOSTS` | empty | Optional comma-separated exact Host values | +| `RECON_REMOTE_ALLOWED_ORIGINS` | empty | Optional comma-separated exact HTTP or HTTPS browser origins | + +`trusted-platform` deliberately removes application-level authentication. Use +it only when the managed runtime authenticates every request and callers cannot +bypass that ingress to reach the container. The Cloud Run Terraform reference +uses it only with Cloud Run IAM. A directly reachable container must use +`static-bearer` or sit behind a separately reviewed OAuth gateway. + +The initial static token is client authentication for a trusted operator or +team. It is not per-user identity, delegated authorization, or a public +multi-tenant service. Rotate it through the platform secret manager. Do not put +it in Terraform variables, image layers, shell history, logs, or client source. + +## Operational limits + +The process retains only bounded in-memory cache and rate-limit state. Multiple +instances do not share cache entries or rate limits. Enforce a maximum instance +count, concurrency, per-identity quotas, and cost alerts at the hosting layer. +Cloud logs can contain queried domain names in application diagnostics, so use +restricted access and a deliberately short retention period. + +For per-user OAuth, identity-provider choices, AI clients, and other clouds, +read the +[cross-platform plan](../../docs/optional-cloud-deployment-plan.md). diff --git a/deploy/gcp-cloud-run/.terraform.lock.hcl b/deploy/gcp-cloud-run/.terraform.lock.hcl new file mode 100644 index 00000000..f64e652c --- /dev/null +++ b/deploy/gcp-cloud-run/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "7.42.0" + constraints = ">= 7.0.0, < 8.0.0" + hashes = [ + "h1:gB0UkvO/UrEXplvJ/7o0YwGTp54NDkEO8jkPzgVrOW8=", + "zh:30b25728203b9208a167fac3f9880c10242fc5accdd29ba01b21355566fc4e3d", + "zh:4468f6ea772e991d890724e44f628a24dae44c9028af654469454d05b00b10ec", + "zh:4dfa4f7bcd72ea89f6f3f7411d88bf9a1f060699830e1f1f85bf32102be754b3", + "zh:59cf73879f10ad9d29ff8ad96559a476e70695bed26b84b6189728129674618c", + "zh:73a7966ae1c6db8a3dc31eb43f05dddd27a47f3ff42e25f62594fc0d5b438412", + "zh:7c2ea415fb06147cf9834b2169d75a52bd979ad291bed32c94d9a9307316f7ba", + "zh:962efdd3dee2b98860528555b0616ca0c8987dfc6c5e5df6d1c025c9b22c2f26", + "zh:c4a5ca9f20cbfbdcb88064d53d16f2ce8038e1ddc3303c7261152856728700b5", + "zh:ca56a9477177530737d07feea70bb99309414a7c18aa62f975644138606e1faf", + "zh:d0c6db8b1da363f69087569716ed96a3a6dadb4b872f598d442d867bd0706fa9", + "zh:ddd2472052e0c5c3fab7cffae8376e8855c35ad8614ba8631f7e448e72a41f21", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/deploy/gcp-cloud-run/README.md b/deploy/gcp-cloud-run/README.md new file mode 100644 index 00000000..4b5594bc --- /dev/null +++ b/deploy/gcp-cloud-run/README.md @@ -0,0 +1,173 @@ +# Draft Optional Google Cloud Run Reference + +This Terraform root module is the first optional scale-to-zero reference for +recon. Google publishes specific guidance for hosting Streamable HTTP MCP +servers on Cloud Run, and Cloud Run matches recon's existing Python container +without a function-framework rewrite. + +Nothing here is required for local use. Nothing deploys automatically. The +operator supplies a Google Cloud project, region, immutable image digest, +authentication choice, and billing approval, then owns the resulting service. + +Status: draft intended to be directionally useful. Terraform formatting, +initialization, and schema validation pass, and the referenced container passes +local build and MCP smoke checks. This module has not yet been applied and +validated through a real Cloud Run project under this plan's promotion gate. +It is not production-ready or a claim that IAM, secret injection, quotas, +autoscaling, billing, logging, rollback, and deletion have been exercised end +to end. + +## Maturity boundary + +Locally checked: + +- Terraform formatting and provider-schema validation with the lockfile +- Immutable image-reference input validation +- Linux container build, health response, authentication rejection, and MCP + initialization +- Unit coverage for the application security boundary + +Still required in a non-production Google Cloud project: + +- Review and apply against the exact project, region, organization policy, and + identities +- Verify both access modes through the managed ingress +- Measure cold and warm behavior, concurrency, quotas, and bounded cost +- Inspect query-bearing logs and set access and retention deliberately +- Rotate the secret version and roll back the immutable image +- Confirm a no-change second plan, deletion protection, and reviewed removal + +Until those steps are recorded, keep calling this a draft reference. + +## What it creates + +- Required Google APIs +- One dedicated service account with no project roles +- One Cloud Run v2 service with minimum instances zero by default +- A bounded maximum of three instances and concurrency eight by default +- Startup and liveness probes against `/health` +- Optional Secret Manager access to one existing bearer-token secret +- Either a public Cloud Run invoker plus application bearer auth, or named + Google IAM invokers plus trusted platform auth + +The module does not create an Artifact Registry repository, a secret value, an +OAuth provider, a domain, a load balancer, or a monitoring project. Those are +organization-level choices and should not be silently inferred. + +## Choose the access mode + +`application-bearer` is the default interoperability mode. Cloud Run's network +invoker is public, but the application rejects every `/mcp` request without the +Secret Manager backed bearer. This mode works with remote AI clients that can +send a static authorization header. It is suitable for a trusted individual or +team, not a public multi-tenant service. Because an unauthenticated request can +still start an instance before the application rejects it, keep the maximum +instance bound, budgets, and alerts in place. Put a reviewed gateway and +identity-aware rate limit in front before broader exposure. + +`google-iam` keeps the Cloud Run invoker private and grants only the members in +`invoker_members`. The container trusts that outer identity boundary. Use it +for Google-hosted agents, service-to-service calls, or a local Cloud Run proxy. +It is not directly usable by an AI service that cannot mint a Google ID token. + +Per-user Claude or ChatGPT plugin access needs an OAuth 2.1 compatible gateway +and authorization server. That is a later, separately gated layer in the +[cross-platform plan](../../docs/optional-cloud-deployment-plan.md). + +## Prerequisites + +- Terraform 1.8 or newer +- Google Cloud credentials authorized to enable APIs, create the service and + service account, and manage the named IAM bindings +- An existing Artifact Registry repository and pushed `linux/amd64` image +- In `application-bearer` mode, an existing Secret Manager secret containing a + random token of at least 32 bytes +- A selected region based on residency, latency, quota, and organization policy + +Build and push the container from the repository root. Tag it for traceability, +then resolve and pass its immutable registry digest to Terraform: + +```powershell +docker build --platform linux/amd64 --file deploy/container/Dockerfile --tag $ImageTag . +docker push $ImageTag +gcloud artifacts docker images describe $ImageTag --format="value(image_summary.digest)" +``` + +Create the secret outside Terraform so its value never enters Terraform state. +For example, after creating an empty secret named `recon-mcp-bearer`, add a +version through standard input: + +```powershell +$token = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(48)) +$token | gcloud secrets versions add recon-mcp-bearer --data-file=- +Remove-Variable token +``` + +Record the numeric version returned by Secret Manager. Do not use `latest` for +an environment-variable secret because a revision should resolve one reviewed +version consistently. + +## Plan and apply + +Create an untracked `terraform.tfvars` or pass variables through the operator's +normal automation. It contains identifiers, never the secret value: + +```hcl +project_id = "example-project" +region = "us-central1" +container_image = "us-central1-docker.pkg.dev/example-project/tools/recon@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +access_mode = "application-bearer" +bearer_secret_id = "recon-mcp-bearer" +bearer_secret_version = "1" +max_instances = 3 +``` + +Then run: + +```powershell +terraform -chdir=deploy/gcp-cloud-run init +terraform -chdir=deploy/gcp-cloud-run fmt -check +terraform -chdir=deploy/gcp-cloud-run validate +terraform -chdir=deploy/gcp-cloud-run plan -out=recon.tfplan +terraform -chdir=deploy/gcp-cloud-run apply recon.tfplan +``` + +Review the plan for the exact project, region, image digest, public IAM member, +secret ID and version, instance bounds, and service account before applying. +The output `mcp_url` is the endpoint for the chosen client. + +For Google IAM mode, omit the secret variables and set explicit members: + +```hcl +access_mode = "google-iam" +invoker_members = [ + "serviceAccount:agent@example-project.iam.gserviceaccount.com", +] +``` + +## Verification and rollback + +After apply: + +1. Confirm `/health` returns 200. +2. Confirm `/mcp` without the required identity returns 401 or 403. +3. Use MCP Inspector or the intended client to initialize, list tools, read one + catalog resource, and call a reserved-domain lookup. +4. Confirm mutating tools such as `reload_data` and + `inject_ephemeral_fingerprint` are absent. +5. Confirm max instances, request timeout, secret version, log retention, cost + budget, and alerting in the deployed project. + +Rollback by applying the previous immutable image digest and secret version. +Deletion protection defaults to true. A deliberate destroy requires first +setting it false and applying that change, then reviewing a separate destroy +plan. + +## Source basis + +This reference was checked on 2026-07-28 against Google's current +[Cloud Run MCP hosting guide](https://docs.cloud.google.com/run/docs/host-mcp-servers), +[concurrency guidance](https://docs.cloud.google.com/run/docs/about-concurrency), +[request timeout contract](https://docs.cloud.google.com/run/docs/configuring/request-timeout), +[Secret Manager integration](https://docs.cloud.google.com/run/docs/configuring/services/secrets), +and [health-check guidance](https://docs.cloud.google.com/run/docs/configuring/healthchecks). diff --git a/deploy/gcp-cloud-run/main.tf b/deploy/gcp-cloud-run/main.tf new file mode 100644 index 00000000..fdf36741 --- /dev/null +++ b/deploy/gcp-cloud-run/main.tf @@ -0,0 +1,169 @@ +locals { + application_bearer = var.access_mode == "application-bearer" + secret_id = coalesce(var.bearer_secret_id, "not-configured") +} + +resource "google_project_service" "required" { + for_each = toset([ + "iam.googleapis.com", + "run.googleapis.com", + "secretmanager.googleapis.com", + ]) + + project = var.project_id + service = each.value + disable_on_destroy = false +} + +resource "google_service_account" "runtime" { + project = var.project_id + account_id = var.service_account_id + display_name = "recon remote MCP runtime" + + depends_on = [google_project_service.required] +} + +resource "google_secret_manager_secret_iam_member" "bearer" { + count = local.application_bearer ? 1 : 0 + + project = var.project_id + secret_id = local.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.runtime.email}" + + depends_on = [google_project_service.required] +} + +resource "google_cloud_run_v2_service" "recon" { + project = var.project_id + name = var.service_name + location = var.region + ingress = "INGRESS_TRAFFIC_ALL" + deletion_protection = var.deletion_protection + labels = merge( + { + application = "recon" + managed-by = "terraform" + surface = "optional-remote-mcp" + }, + var.labels, + ) + + template { + service_account = google_service_account.runtime.email + timeout = "${var.request_timeout_seconds}s" + max_instance_request_concurrency = var.container_concurrency + + scaling { + min_instance_count = var.min_instances + max_instance_count = var.max_instances + } + + containers { + image = var.container_image + + ports { + name = "http1" + container_port = 8080 + } + + env { + name = "RECON_REMOTE_AUTH_MODE" + value = local.application_bearer ? "static-bearer" : "trusted-platform" + } + + env { + name = "RECON_REMOTE_ALLOWED_HOSTS" + value = join(",", sort(tolist(var.allowed_hosts))) + } + + env { + name = "RECON_REMOTE_ALLOWED_ORIGINS" + value = join(",", sort(tolist(var.allowed_origins))) + } + + dynamic "env" { + for_each = local.application_bearer ? [1] : [] + content { + name = "RECON_REMOTE_BEARER_TOKEN" + value_source { + secret_key_ref { + secret = "projects/${var.project_id}/secrets/${local.secret_id}" + version = var.bearer_secret_version + } + } + } + } + + resources { + limits = { + cpu = "1" + memory = "1Gi" + } + cpu_idle = true + startup_cpu_boost = true + } + + startup_probe { + initial_delay_seconds = 0 + timeout_seconds = 2 + period_seconds = 2 + failure_threshold = 30 + + http_get { + path = "/health" + port = 8080 + } + } + + liveness_probe { + initial_delay_seconds = 0 + timeout_seconds = 2 + period_seconds = 30 + failure_threshold = 3 + + http_get { + path = "/health" + port = 8080 + } + } + } + } + + lifecycle { + precondition { + condition = !local.application_bearer || var.bearer_secret_id != null + error_message = "bearer_secret_id is required in application-bearer mode." + } + + precondition { + condition = var.max_instances >= var.min_instances + error_message = "max_instances must be greater than or equal to min_instances." + } + } + + depends_on = [ + google_project_service.required, + google_secret_manager_secret_iam_member.bearer, + ] +} + +resource "google_cloud_run_v2_service_iam_member" "public" { + count = local.application_bearer ? 1 : 0 + + project = google_cloud_run_v2_service.recon.project + location = google_cloud_run_v2_service.recon.location + name = google_cloud_run_v2_service.recon.name + role = "roles/run.invoker" + member = "allUsers" +} + +resource "google_cloud_run_v2_service_iam_member" "named_invokers" { + for_each = local.application_bearer ? toset([]) : var.invoker_members + + project = google_cloud_run_v2_service.recon.project + location = google_cloud_run_v2_service.recon.location + name = google_cloud_run_v2_service.recon.name + role = "roles/run.invoker" + member = each.value +} diff --git a/deploy/gcp-cloud-run/outputs.tf b/deploy/gcp-cloud-run/outputs.tf new file mode 100644 index 00000000..b9af62e9 --- /dev/null +++ b/deploy/gcp-cloud-run/outputs.tf @@ -0,0 +1,19 @@ +output "service_url" { + description = "Cloud Run service base URL." + value = google_cloud_run_v2_service.recon.uri +} + +output "mcp_url" { + description = "Remote Streamable HTTP MCP endpoint." + value = "${google_cloud_run_v2_service.recon.uri}/mcp" +} + +output "access_mode" { + description = "Authentication boundary selected for this deployment." + value = var.access_mode +} + +output "runtime_service_account" { + description = "Least-privilege identity used by the Cloud Run revision." + value = google_service_account.runtime.email +} diff --git a/deploy/gcp-cloud-run/variables.tf b/deploy/gcp-cloud-run/variables.tf new file mode 100644 index 00000000..de39556d --- /dev/null +++ b/deploy/gcp-cloud-run/variables.tf @@ -0,0 +1,140 @@ +variable "project_id" { + description = "Google Cloud project that owns the optional recon service." + type = string +} + +variable "region" { + description = "Cloud Run region. Choose it for user proximity, policy, and service availability." + type = string + default = "us-central1" +} + +variable "service_name" { + description = "Cloud Run service name." + type = string + default = "recon-mcp" + + validation { + condition = can(regex("^[a-z][a-z0-9-]{0,47}[a-z0-9]$", var.service_name)) + error_message = "service_name must be 2 to 49 lowercase letters, digits, or hyphens and must start with a letter." + } +} + +variable "service_account_id" { + description = "Account ID for the dedicated Cloud Run runtime identity." + type = string + default = "recon-mcp-runtime" +} + +variable "container_image" { + description = "Immutable Artifact Registry or other Cloud Run image reference, pinned by sha256 digest." + type = string + + validation { + condition = can(regex("@sha256:[0-9a-f]{64}$", var.container_image)) + error_message = "container_image must end in an immutable @sha256 digest." + } +} + +variable "access_mode" { + description = "application-bearer supports remote AI services; google-iam supports Google identities and private clients." + type = string + default = "application-bearer" + + validation { + condition = contains(["application-bearer", "google-iam"], var.access_mode) + error_message = "access_mode must be application-bearer or google-iam." + } +} + +variable "bearer_secret_id" { + description = "Existing Secret Manager secret ID containing a random bearer token. Required only for application-bearer mode." + type = string + default = null + nullable = true +} + +variable "bearer_secret_version" { + description = "Numeric Secret Manager version used by the Cloud Run revision." + type = string + default = "1" + + validation { + condition = can(regex("^[1-9][0-9]*$", var.bearer_secret_version)) + error_message = "bearer_secret_version must be a numeric secret version, not latest." + } +} + +variable "invoker_members" { + description = "Google IAM members granted roles/run.invoker in google-iam mode." + type = set(string) + default = [] +} + +variable "allowed_hosts" { + description = "Optional exact Host header values enforced inside the container." + type = set(string) + default = [] +} + +variable "allowed_origins" { + description = "Optional exact browser origins. Empty rejects every request that carries an Origin header." + type = set(string) + default = [] +} + +variable "min_instances" { + description = "Minimum Cloud Run instances. Keep zero for the optional scale-to-zero path." + type = number + default = 0 + + validation { + condition = var.min_instances >= 0 && floor(var.min_instances) == var.min_instances + error_message = "min_instances must be a non-negative integer." + } +} + +variable "max_instances" { + description = "Hard cost and fan-out bound for Cloud Run autoscaling." + type = number + default = 3 + + validation { + condition = var.max_instances >= 1 && floor(var.max_instances) == var.max_instances + error_message = "max_instances must be a positive integer." + } +} + +variable "container_concurrency" { + description = "Maximum in-flight requests per instance. Start low for the I/O-heavy Python resolver." + type = number + default = 8 + + validation { + condition = var.container_concurrency >= 1 && var.container_concurrency <= 1000 + error_message = "container_concurrency must be between 1 and 1000." + } +} + +variable "request_timeout_seconds" { + description = "Cloud Run request timeout. This must exceed recon's bounded lookup timeout." + type = number + default = 180 + + validation { + condition = var.request_timeout_seconds >= 130 && var.request_timeout_seconds <= 3600 + error_message = "request_timeout_seconds must be between 130 and 3600." + } +} + +variable "deletion_protection" { + description = "Protect the Cloud Run service from accidental Terraform deletion." + type = bool + default = true +} + +variable "labels" { + description = "Additional billing and ownership labels." + type = map(string) + default = {} +} diff --git a/deploy/gcp-cloud-run/versions.tf b/deploy/gcp-cloud-run/versions.tf new file mode 100644 index 00000000..46d6ff03 --- /dev/null +++ b/deploy/gcp-cloud-run/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 7.0, < 8.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} diff --git a/docs/README.md b/docs/README.md index b7c7a407..a2c1ab02 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ The docs are organized by reader need: | Dependency-ordered implementation plan | [engineering-refinement-plan.md](engineering-refinement-plan.md) | | Structural maintainability audit and refactor plan | [structural-maintainability.md](structural-maintainability.md) | | Time-bound MCP 2026 compatibility plan | [mcp-2026-07-28-readiness.md](mcp-2026-07-28-readiness.md) | +| Draft optional remote MCP and cloud scale-out framework, not yet provider-validated | [optional-cloud-deployment-plan.md](optional-cloud-deployment-plan.md) | | What recon can and cannot see | [limitations.md](limitations.md) | | Rules versus agent judgment | [agentic-balance.md](agentic-balance.md) | diff --git a/docs/adr/0009-mcp-2026-readiness.md b/docs/adr/0009-mcp-2026-readiness.md index 59f037a6..96965f4e 100644 --- a/docs/adr/0009-mcp-2026-readiness.md +++ b/docs/adr/0009-mcp-2026-readiness.md @@ -93,9 +93,20 @@ The decision is enforced by: - the readiness gate in [../mcp-2026-07-28-readiness.md](../mcp-2026-07-28-readiness.md) The exact v1.28.1 and v2.0.0b1 matrix met the candidate validation target on -2026-07-13. The final adoption target remains: +2026-07-13. At that checkpoint, the remaining final compatibility target was: - `recon mcp doctor` passes with the updated discovery path. - Structured tools still advertise output schemas. - Deprecated features remain absent. - Full repository checks pass before any compatibility claim is made. + +## Final Compatibility Outcome + +On 2026-07-28, the exact v1.28.1 and stable v2.0.0 matrix passed the same +isolated compatibility gate. Stable v2 passed `server/discover`, deterministic +registration of 22 tools, five resources and one prompt, 44 JSON Schema +documents, structured success and error behavior, concurrent catalog reloads, +stdio framing, resource reads, the live doctor, and complete-result cache +metadata. This closes the final compatibility target. It does not widen the +production dependency or add remote transport; those remain separate release +and architecture decisions under this ADR. diff --git a/docs/engineering-refinement-plan.md b/docs/engineering-refinement-plan.md index 6dd7146f..3fc8ad0d 100644 --- a/docs/engineering-refinement-plan.md +++ b/docs/engineering-refinement-plan.md @@ -122,19 +122,20 @@ Correct the smallest evidence-to-claim path first. ## Track 2: MCP 2026-07-28 Compatibility Matrix -Status: candidate checkpoint complete 2026-07-13; final adoption gate pending -Dependencies: none; this time-bound stream can proceed independently of Track 1 -Risk: time-bound dependency and protocol compatibility +Status: stable compatibility checkpoint complete 2026-07-28; production +adoption decision pending +Dependencies: none; this stream can proceed independently of Track 1 +Risk: dependency and protocol compatibility -The exact stable v1.28.1 and candidate v2.0.0b1 environments pass the full -isolated matrix. Production remains on `mcp>=1.28.1,<2`; final adoption waits -for the final specification, stable v2 SDK, and another full gate. +The exact stable v1.28.1 and stable v2.0.0 environments pass the full isolated +matrix. Production remains on `mcp>=1.28.1,<2`; adopting v2 remains a separate +release decision. ### Scope - Test server import, stdio startup, doctor, discovery, representative tool calls, resource reads, error behavior, structured content, schemas, and - deterministic order on v1.28.1 and v2 beta. + deterministic order on stable v1.28.1 and stable v2.0.0. - Record an explicit migration decision for `FastMCP`, protocol type imports, `ToolError`, annotations, snake-case SDK attributes, `discover()`, and `model_dump(by_alias=True)` where required on the wire. @@ -320,8 +321,8 @@ approved. Status: Python optimization checkpoints implemented; product-shaped async and v2 deltas remain -Dependencies: none for resolver and current-schema baselines; Track 2 only for -candidate-SDK deltas +Dependencies: none for resolver and current-schema baselines; stable-v2 deltas +are available from the completed Track 2 matrix Risk: concurrency and brittle-benchmark risk ### Scope @@ -546,12 +547,10 @@ Execute this track in four bounded phases: ## Execution Order -1. Treat evidence-semantic corrections and the time-bound MCP v2 matrix as two - independent Now streams. Keep one atomic implementation item in progress at - a time, but do not make either stream wait on a false technical dependency. - The first machine-enforced claim contract and candidate MCP matrix are - complete. Freeze the claim contract's unit and label boundaries before - benchmark enrollment, and keep the matrix blocking until final v2 review. +1. Keep evidence-semantic corrections as the active trust stream and the + completed stable MCP v1/v2 matrix as a blocking regression stream. The first + machine-enforced claim contract and stable MCP matrix are complete. Freeze + the claim contract's unit and label boundaries before benchmark enrollment. 2. Run the stable-v1 resolver, allocation, CT-value, and schema characterization from Track 5. 3. Complete the product-quality scorecard and ablation using that artifact. @@ -561,7 +560,7 @@ Execute this track in four bounded phases: and retirement rule. 6. Qualify or demote CT graph correlation before adding graph machinery. 7. Decide the dimensioned email-observation model from measured evidence. -8. Apply candidate-SDK deltas to the Track 5 characterization after Track 2. +8. Apply stable-v2 SDK deltas from Track 2 to the Track 5 characterization. 9. Baseline and improve catalog quality. 10. Measure and, only if justified, simplify operator and agent discovery. 11. Decompose critical interface hotspots without changing behavior. diff --git a/docs/mcp-2026-07-28-readiness.md b/docs/mcp-2026-07-28-readiness.md index 72ce5870..0b252795 100644 --- a/docs/mcp-2026-07-28-readiness.md +++ b/docs/mcp-2026-07-28-readiness.md @@ -1,22 +1,20 @@ # MCP 2026-07-28 Readiness Plan -Status: candidate compatibility matrix complete; final adoption gate pending -Review date: 2026-07-13 +Status: final stable compatibility matrix complete; production adoption +decision pending +Review date: 2026-07-28 -The Model Context Protocol 2026-07-28 release candidate was published on -2026-05-21, with the final specification scheduled for 2026-07-28. It is a -breaking protocol release. The official Python SDK `2.0.0b1` shipped on -2026-06-30 with draft-2026 support, so the compatibility-spike trigger is now -met. recon completed the isolated candidate characterization without -publishing a prerelease dependency or implementing unused surface area. +The Model Context Protocol 2026-07-28 specification and official Python SDK +`2.0.0` were published on 2026-07-28. This is a breaking protocol release. +recon completed the isolated final characterization without widening the +production dependency or implementing unused surface area. The earlier +candidate result remains documented below as migration history. Sources: - [MCP 2026-07-28 release candidate blog](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) -- [MCP draft specification](https://modelcontextprotocol.io/specification/draft) -- [MCP draft changelog](https://modelcontextprotocol.io/specification/draft/changelog) -- [MCP draft caching specification](https://modelcontextprotocol.io/specification/draft/server/utilities/caching) -- [MCP Python SDK 2.0.0b1 release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.0.0b1) +- [MCP current documentation](https://modelcontextprotocol.io/docs/getting-started/intro) +- [MCP Python SDK 2.0.0 release](https://pypi.org/project/mcp/2.0.0/) - [MCP Python SDK v2 migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) - [MCP Python SDK release history](https://pypi.org/project/mcp/) @@ -33,8 +31,8 @@ boundary: The declared dependency range is `mcp>=1.28.1,<2`, and the lock resolves to stable v1.28.1. The live doctor uses that SDK's -`ClientSession.initialize()` and `tools/list` flow. Candidate SDK v2.0.0b1 -instead uses `server/discover`, `MCPServer`, `mcp_types`, snake-case Python +`ClientSession.initialize()` and `tools/list` flow. Stable SDK v2.0.0 instead +uses `server/discover`, `MCPServer`, `mcp_types`, snake-case Python attributes, wire aliases, and worker threads for synchronous handlers. The same server registration and domain logic now passes on both generations. @@ -42,9 +40,10 @@ recon does not currently operate a remote Streamable HTTP MCP server, does not implement MCP OAuth flows, and does not use Roots, Sampling, or MCP Logging. Those facts materially reduce the immediate blast radius. -## Dated Compatibility Result +## Dated Compatibility Results -The isolated working-tree matrix completed on 2026-07-13. It exported the +The candidate isolated working-tree matrix completed on 2026-07-13. The final +stable matrix completed on 2026-07-28. Each run exported the locked production runtime constraints, replaced only the exact MCP pin, and installed the editable working tree into a separate environment under the gitignored `.agent/` workspace. Package installation used the configured @@ -54,9 +53,10 @@ package index; all recon probes after installation were local and network-free. |---|---|---|---|---| | 1.0.0 | incompatible | unavailable | unavailable | Unsupported. It exposes neither server API recon requires, so the former dependency floor was not truthful. | | 1.28.1 | pass | `initialize` | event-loop thread | Production floor and rollback line. Sixteen required checks passed; three v2-only checks were not applicable. | -| 2.0.0b1 | pass | `server/discover` | AnyIO worker thread | Candidate-compatible only. Nineteen checks passed; production remains below v2. | +| 2.0.0b1 | pass | `server/discover` | AnyIO worker thread | Historical candidate checkpoint from 2026-07-13. | +| 2.0.0 | pass | `server/discover` | AnyIO worker thread | Final stable compatibility checkpoint from 2026-07-28; production remains below v2 pending a separate adoption review. | -Both passing rows proved the same deterministic inventory of 22 tools, five +The passing rows proved the same deterministic inventory of 22 tools, five resources, zero resource templates, and one `domain_report` prompt. The matrix validated 44 input and output schema documents as JSON Schema 2020-12 with no external output references, representative structured success and `ToolError` @@ -76,11 +76,10 @@ longer TTL requires separate freshness evidence. Reproduce both supported rows with: ```bash -uv run python scripts/check_mcp_compatibility.py --sdk-version 1.0.0 --sdk-version 1.28.1 --sdk-version 2.0.0b1 --require-compatible 1.28.1 --require-compatible 2.0.0b1 +uv run python scripts/check_mcp_compatibility.py --sdk-version 1.0.0 --sdk-version 1.28.1 --sdk-version 2.0.0 --require-compatible 1.28.1 --require-compatible 2.0.0 ``` -This is a candidate compatibility result, not a claim of compatibility with an -unpublished final specification or stable v2 SDK. +This is a compatibility result, not a production dependency change. ## RC Changes That Matter to recon @@ -93,7 +92,7 @@ unpublished final specification or stable v2 SDK. exposes them through FastMCP. - Mandatory `ttlMs` and `cacheScope` hints on every complete `server/discover`, tool list, prompt list, resource list, resource-template - list, and resource-read result. SDK `2.0.0b1` exposes cache-hint support. + list, and resource-read result. SDK `2.0.0` exposes cache-hint support. - Full JSON Schema 2020-12 for tool schemas, with external `$ref` and validation-boundary requirements. - Deterministic tool, prompt, and resource listing. recon already tries to be @@ -124,10 +123,10 @@ unpublished final specification or stable v2 SDK. 1. Keep the local stdio server as the supported MCP surface. 2. Do not implement remote Streamable HTTP, OAuth, Apps, or Tasks for this readiness track. -3. Keep the exact-pinned v1.28.1 and v2.0.0b1 compatibility matrix blocking in - CI, then repeat it for the final specification and stable v2 SDK. -4. Keep production on stable v1 and `<2` until the final specification and - stable v2 SDK pass every compatibility and release gate. +3. Keep the exact-pinned stable v1.28.1 and v2.0.0 compatibility matrix + blocking in CI. +4. Keep production on stable v1 and `<2` until a separate adoption review + approves a dependency-range change. 5. Build compatibility around the doctor, tool/resource discovery, schemas, wire aliases, and worker-thread behavior using observed SDK behavior rather than a speculative adapter. @@ -158,15 +157,15 @@ Exit criteria: ### Phase 1: Isolated SDK Compatibility Matrix -Status: complete for exact SDKs 1.28.1 and 2.0.0b1 on 2026-07-13. +Status: complete for exact stable SDKs 1.28.1 and 2.0.0 on 2026-07-28. Work: -- Keep a clean compatibility environment exact-pinned to `mcp==2.0.0b1`. +- Keep a clean compatibility environment exact-pinned to `mcp==2.0.0`. Production metadata and the lock stay on stable v1. - Keep server import, stdio startup, doctor, representative tool calls, resource reads, errors, schemas, structured output, and deterministic order - green against stable v1.28.1 and v2 beta. + green against stable v1.28.1 and stable v2.0.0. - Preserve the proven migration boundary for `FastMCP` to `MCPServer`, `mcp.types` to `mcp_types`, `ToolError`, `ToolAnnotations`, snake-case Python attributes, `discover()`, and wire serialization aliases. @@ -194,10 +193,10 @@ Exit criteria: ### Phase 2: Schema, Cache, and Compact Output -Status: candidate schema and cache requirements characterized; final stable-v2 -adoption remains pending. +Status: final stable-v2 schema and cache requirements characterized; +production adoption remains pending. -Trigger: Phase 1 records a viable compatibility path. The candidate SDK already +Trigger: Phase 1 records a viable compatibility path. The stable SDK exposes cache-hint support; lack of an integration point is a compatibility failure to resolve, not a reason to omit mandatory wire behavior. @@ -252,7 +251,7 @@ Exit criteria: ## Test Plan -During the beta compatibility matrix, add or adjust tests for: +The compatibility matrix covers tests for: - Doctor discovery path selection. - Deterministic tool and resource ordering. @@ -261,7 +260,7 @@ During the beta compatibility matrix, add or adjust tests for: - Required cache metadata on discovery, tool-list, resource-list, resource-template-list, and resource-read results, plus an explicit prompts-list disposition. -- Legacy and candidate SDK import, discovery, serialization, and worker-thread +- Legacy and stable-v2 SDK import, discovery, serialization, and worker-thread behavior. - Declared dependency-floor coverage or an evidence-backed floor increase. - Deprecated-feature absence: no Roots, Sampling, MCP Logging, or HTTP+SSE @@ -274,8 +273,8 @@ During the beta compatibility matrix, add or adjust tests for: - `docs/roadmap.md`: keep this readiness track listed under near-term hardening. - `docs/adr/0009-mcp-2026-readiness.md`: record why recon keeps stable v1 in - production until the final specification and stable v2 SDK pass the full - gate, while preserving stdio as the supported MCP surface. + production after stable-v2 compatibility is proven, while preserving stdio + as the supported MCP surface. - `CHANGELOG.md`: mention the compatibility result when code, dependency metadata, or user-facing behavior changes. @@ -291,7 +290,8 @@ During the beta compatibility matrix, add or adjust tests for: ## Final Readiness Gate -Before claiming recon is compatible with the final MCP 2026-07-28 release: +Status: compatibility gate passed on 2026-07-28. Before changing the +production dependency: - Local tests pass. - `uv run python scripts/check.py` passes. @@ -301,5 +301,5 @@ Before claiming recon is compatible with the final MCP 2026-07-28 release: - Every complete cacheable result recon exposes carries valid `ttlMs` and `cacheScope` hints under the 2026 protocol. - MCP docs name the supported protocol behavior accurately. -- The candidate matrix is rerun against the final specification and stable SDK, - with any delta documented before the production dependency changes. +- The exact stable v1.28.1 and v2.0.0 matrix remains blocking in CI, with any + future delta documented before the production dependency changes. diff --git a/docs/mcp.md b/docs/mcp.md index 46080f0d..4673b6d1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -14,6 +14,15 @@ Works with Claude Desktop, Cursor, VS Code + Copilot, ChatGPT, or any other > configure permissions through the client's documented controls, and prefer > an isolated workspace or container for production agent use. +The local stdio server is the default. An entirely optional, operator-owned +remote Streamable HTTP container and Cloud Run reference are documented in the +[cloud deployment plan](optional-cloud-deployment-plan.md). They are for users +who benefit from shared access or bounded scale-out; they are not required for +any local workflow, and the project does not operate a hosted endpoint. The +remote materials are a draft framework intended to be directionally useful. +They have local artifact checks but are not yet provider-validated or +production-ready. + ## Setup 1. Install recon: @@ -187,12 +196,12 @@ Passing `result_limit`, `peer_limit_per_domain`, `member_limit_per_cluster`, omitted counts, a deterministic `selection_rule`, and a `raw_request` pointer so an agent can decide whether to request the raw result. -Compatibility with the MCP 2026-07-28 release candidate is tracked in +Compatibility with the final MCP 2026-07-28 release is tracked in [mcp-2026-07-28-readiness.md](mcp-2026-07-28-readiness.md). The dated isolated -matrix passes on both stable SDK v1.28.1 and candidate SDK v2.0.0b1 using the -same local stdio server. Production stays on `mcp>=1.28.1,<2` until the final -specification and stable v2 SDK pass recon's doctor, discovery, schema, -resource, ordering, and full CI gates. Under the candidate, recon explicitly +matrix passes on both stable SDK v1.28.1 and stable SDK v2.0.0 using the same +local stdio server. Production stays on `mcp>=1.28.1,<2` pending a separate +adoption review; stable v2 already passes recon's doctor, discovery, schema, +resource, ordering, and compatibility gates. Under v2, recon explicitly uses conservative `ttlMs=0`, `cacheScope=private` hints for all six cacheable methods rather than promising freshness it cannot establish. @@ -506,7 +515,7 @@ installed server; the third validates that the named client was told about it. fingerprint detection summaries must be populated; and the schema and surface inventory must retain their identifying contract structure. These resource reads are local and make no target network request. Stable v1 uses - `initialize`; candidate v2 uses + `initialize`; stable v2 uses `server/discover` and validates complete-result cache metadata for discovery, listing, and reads. A failure retains completed check rows and names the failed protocol phase. Spawned-server stderr is limited to its trailing diff --git a/docs/optional-cloud-deployment-plan.md b/docs/optional-cloud-deployment-plan.md new file mode 100644 index 00000000..ef68f5d2 --- /dev/null +++ b/docs/optional-cloud-deployment-plan.md @@ -0,0 +1,661 @@ +# Draft Optional Cloud Access and Scale-Out Framework + +Status: draft, low-priority optional depth and polish. The local CLI and local +stdio MCP server remain the default and complete product. A shared runtime, +container, and Google Cloud Run Terraform starting point are checked in. This +draft is intended to be directionally useful, not a validated production +deployment. It has not yet passed a real cloud-provider promotion gate. The +project does not operate a hosted endpoint. + +Research review date: 2026-07-28. + +## Decision + +The directionally recommended way to let people use recon at greater scale +with the AI system of their choice is one portable boundary: + +```text +AI or agent with an MCP client + | + | authenticated HTTPS, Streamable HTTP MCP + v +operator-owned ingress and identity boundary + | + v +stateless recon container, bounded concurrency and scale + | + v +the same public DNS, identity, CT, and MTA-STS sources as local recon +``` + +The AI provider and the compute provider are separate choices. Anthropic and +OpenAI can consume a remote MCP endpoint, but neither is a general-purpose +host for recon's Python process. AWS, Google Cloud, Azure, Cloudflare, a +Kubernetes platform, or any ordinary OCI host can run that process. A user +should not need a different recon implementation for each model vendor. + +This means: + +1. Keep `recon ` and `recon mcp` local-first and unchanged. +2. Package one optional, stateless, authenticated Streamable HTTP adapter. +3. Put provider-specific IaC around that adapter only where the platform path + is mature and testable. +4. Let Claude, OpenAI, Foundry, Bedrock, Vertex-based agents, and generic MCP + clients consume the same contract when their client surface supports remote + MCP. +5. Use a thin MCP client inside the agent application when an AI API does not + provide a first-party remote MCP connector. + +Serverless containers are the default scale-out shape. Functions remain valid +secondary adapters, but they add protocol bridges, framework constraints, or +authentication gaps for a Python MCP server whose calls can spend up to 120 +seconds waiting on bounded public-network sources. + +## What Optional Means + +This track must never become an installation requirement or a prerequisite for +the CLI, JSON output, local stdio MCP, development, tests, or releases. + +- A local user installs only `recon-tool`; no cloud SDK, Terraform provider, or + container runtime is required. +- The project does not run a central endpoint, collect telemetry, receive + query logs, manage users, or pay an operator's cloud bill. +- IaC is a reviewed reference, not an automatic deployment or support promise + for every provider feature. +- An operator chooses and owns the account, region, identity provider, secret + rotation, log retention, quotas, budgets, upgrades, and incident response. +- Cloud-specific work stays below the three core roadmap priorities. It may + proceed as bounded polish when it does not displace claim truthfulness, MCP + compatibility, or the product-quality baseline. +- A project-operated public SaaS or multi-tenant recon service is not planned. + +## Maturity and Evidence Labels + +This plan uses four maturity levels so research, checked-in files, and real +operational evidence are not conflated: + +| Label | Meaning | +|---|---| +| Research direction | Current first-party documentation supports the approach, but no maintained recon artifact exists for it | +| Draft artifact | Code or IaC exists and passes local repository checks, but has not passed a real provider deployment gate | +| Provider-validated reference | A named operator has applied it in a non-production provider account and completed the promotion checklist below | +| Production-proven | A named operator has additionally supplied bounded load, cost, rotation, retention, rollback, and operating evidence | + +Current state: the shared adapter, container, and Cloud Run Terraform are draft +artifacts. AWS, Azure, Cloudflare, Kubernetes, OAuth, and AI-client sections are +research directions or integration guidance. No path in this document is yet +claimed as a provider-validated reference or production-proven deployment. + +Repository CI can prove syntax, tests, a Linux image build, and a local MCP +handshake. It cannot prove cloud IAM behavior, organization policy, regional +availability, quotas, managed-secret injection, public ingress controls, +autoscaling, cold starts, billing, logs, or rollback on a real provider. + +## Who Should Use Which Path + +| Need | Directional starting point | Current maturity | Why | +|---|---|---|---| +| One person or one desktop agent | Local CLI or stdio MCP | Shipped local path | Zero cloud cost, lowest latency, simplest trust boundary | +| A trusted team using different AI clients | Optional container on Cloud Run or another OCI host | Draft artifact | One model-neutral endpoint, scale to zero, bounded cost | +| Google-only private agents | Cloud Run with Google IAM | Draft artifact | Platform identity can remain closed to public invocation | +| AWS-native agent platform | Bedrock AgentCore Runtime after the IaC gate below | Research direction | Purpose-built MCP hosting, JWT auth, session and gateway options | +| Microsoft Foundry with private networking | Azure Container Apps with internal ingress | Research direction | Foundry's documented private MCP path | +| Edge-native TypeScript tools | Cloudflare Workers | Research direction | Native remote MCP handler and OAuth ecosystem | +| Existing enterprise platform with sustained traffic | Kubernetes or an existing container service | Research direction | Reuse established ingress, identity, policy, and observability | +| OpenAI access to private or on-prem compute | Local container plus OpenAI Secure MCP Tunnel | Integration guidance | Keeps the runtime private while OpenAI consumes it through the tunnel | + +## Shared Runtime Contract + +The draft adapter is [src/recon_tool/remote_server.py](../src/recon_tool/remote_server.py). +It deliberately is not a new CLI command, so the stable local CLI surface does +not change. + +### Protocol + +- HTTPS at the managed ingress. +- Streamable HTTP MCP at `/mcp`. +- Stateless mode with JSON responses. +- Process health at `/health`. +- No legacy SSE-only deployment. +- Production MCP SDK remains `mcp>=1.28.1,<2` until the separate stable-v2 + adoption review changes it. The remote adapter refuses to start on an + unadopted SDK family rather than guessing at a production transport. + +Stateless mode fits recon because the meaningful result is derived from each +tool call plus bounded process cache. recon does not need sampling, elicitation, +or multi-turn server state. It also lets a serverless platform route each +request independently and scale to zero. + +### Remote tool boundary + +The remote process exposes only tools with an explicit `readOnlyHint=true`. +It also removes `list_ephemeral_fingerprints`, because its corresponding write +tools are absent. The following local or session-mutating tools cannot appear: + +- `inject_ephemeral_fingerprint` +- `clear_ephemeral_fingerprints` +- `reevaluate_domain` +- `reload_data` +- `list_ephemeral_fingerprints` + +Catalog resources, the prompt, and the remaining read-only tools stay +available. This is a remote safety boundary, not the deferred core-versus- +advanced context-optimization profile. + +### HTTP boundary + +- Static bearer mode fails startup without an ASCII token of at least 32 + bytes. +- Token comparison uses constant-time digest comparison. +- Trusted platform mode must be selected explicitly and is safe only when the + container cannot be reached around the platform's authenticated ingress. +- Duplicate or malformed authentication, Host, Origin, and Content-Length + headers fail closed. +- Browser Origin headers are rejected by default. Exact origins can be added + when a reviewed browser client exists. +- Request bodies are buffered only up to a one MiB default and a hard 16 MiB + configuration ceiling. +- Responses add `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, and + `X-Content-Type-Options: nosniff`. +- Uvicorn access logging is disabled. Application diagnostics can still name a + queried domain and therefore require restricted access and short retention. + +### Runtime and scale defaults + +- Non-root Linux container. +- Read-only installed application, with only the user's bounded recon cache in + its home directory. +- One vCPU and one GiB memory as the initial Cloud Run profile. +- Concurrency eight per instance, based on Google's recommendation to begin at + a lower concurrency when Python resource behavior is not yet load-tested. +- Minimum instances zero and maximum instances three. +- Request timeout 180 seconds, leaving margin above recon's bounded 120-second + resolution timeout. +- Immutable image digest and numeric secret version for each revision. +- No cross-instance cache or distributed rate limiter in the initial version. + +The maximum instance count is both a cost control and an abuse bound. A real +multi-user deployment also needs identity-aware quotas at the ingress because +the in-process per-domain rate limiter is not shared across instances. + +## Authentication Evolution + +Authentication should grow in explicit stages rather than treating one shared +secret as a user system. + +### Stage A: trusted client or team + +Present in the draft adapter and locally checked: + +- Static random bearer stored in the provider secret manager, or +- platform IAM when every intended client can mint that platform's token. + +This authenticates a client or trusted team. It does not identify individual +people, express scopes, support revocation per user, or create a multi-tenant +boundary. Static bearer mode is appropriate for OpenAI's explicit +`authorization` parameter and Anthropic's beta static request-header option, +subject to the client's own product controls. + +### Stage B: per-user OAuth + +Required before describing the endpoint as per-user or broadly shared: + +- OAuth 2.1 authorization code flow with PKCE S256. +- Protected Resource Metadata and a correct 401 `WWW-Authenticate` challenge. +- Authorization Server Metadata. +- Client ID Metadata Documents where supported, with Dynamic Client + Registration as a compatibility fallback. +- Short-lived access tokens, refresh-token rotation, audience restriction, + issuer verification, and explicit recon scopes. +- Subject-derived identity, per-subject quotas, revocation, and audit events. +- No token, authorization code, DNS value, or MCP request body in logs. + +The authorization server should be a mature external identity system rather +than custom password or token issuance code in recon. Auth0 is notable on AWS +because AWS's AgentCore MCP guide uses it for Dynamic Client Registration. +Entra ID, Okta, WorkOS, Stytch, Cognito, and other providers remain viable only +after their client registration and MCP discovery behavior is verified for the +chosen AI client. + +### Stage C: public directory or multi-organization use + +Not planned without a new product decision. It would require tenant isolation, +terms and abuse handling, privacy review, support ownership, billing controls, +data retention policy, external security review, and an availability target. +Publishing to an AI vendor directory is distribution, not hosting, and does +not remove those duties. + +## AI and Agent Consumer Plans + +### Anthropic and Claude + +Status: integration guidance only. It has not yet been exercised against a +provider-validated recon endpoint through Claude's managed connector surface. + +Anthropic's current guidance says to build the remote MCP server with OAuth +first, then add a plugin for distribution. Claude custom connectors use a +remote Streamable HTTP endpoint. OAuth is the per-user route. Team and +Enterprise administrators can also configure beta static request headers for +one organization-wide credential. + +Plan: + +1. Initial trusted-team use can point a custom connector at the Cloud Run + `/mcp` URL and supply the static bearer through the supported header UI. +2. Do not call that per-user authentication. Every user shares the same remote + credential and authorization boundary. +3. Add a DCR or CIMD-capable OAuth gateway before public connector use. +4. Add human-readable tool titles and complete the Anthropic connector review + checklist before considering directory submission. +5. Treat directory submission as optional distribution work after real demand, + not a condition for using Claude with a private connector. + +Anthropic consumes this endpoint. It does not provide a general service that +runs recon's Python package for the operator. + +Primary sources: + +- [What to build for Claude](https://claude.com/docs/connectors/building/what-to-build) +- [Build remote connectors](https://claude.com/docs/connectors/building) +- [Connector authentication](https://claude.com/docs/connectors/building/authentication) +- [Custom remote connectors](https://claude.com/docs/connectors/custom/remote-mcp) +- [Connector review criteria](https://claude.com/docs/connectors/building/review-criteria) +- [Anthropic API MCP connector](https://platform.claude.com/docs/en/agents-and-tools/mcp-connector) + +### OpenAI and ChatGPT + +Status: integration guidance only. It has not yet been exercised against a +provider-validated recon endpoint through the Responses API, ChatGPT plugin +surface, or Secure MCP Tunnel. + +OpenAI's Responses API can call remote MCP servers on the public Internet and +accepts an authorization bearer. It supports tool allowlists, deferred loading, +and approval policies. ChatGPT plugin distribution uses a fixed or templated +remote MCP endpoint and expects OAuth for authenticated per-user use. OpenAI's +Secure MCP Tunnel is the relevant private or on-prem access option. It connects +to compute; it does not replace that compute. + +Plan: + +1. Use `authorization` for the initial trusted-client bearer. +2. Set `allowed_tools` to the smallest task-specific subset in each API call. +3. Keep approvals enabled until the operator deliberately narrows them. +4. Use the Secure MCP Tunnel when public ingress is unacceptable. +5. Add the shared OAuth stage before ChatGPT plugin publication. +6. Review the remote MCP provider's own retention and residency because the AI + API's data controls do not govern the independently operated endpoint. + +Primary sources: + +- [OpenAI connectors and remote MCP](https://developers.openai.com/api/docs/guides/tools-connectors-mcp) +- [Build an OpenAI remote MCP server](https://developers.openai.com/api/docs/mcp) +- [Deploy a plugin MCP endpoint](https://developers.openai.com/plugins/build/mcp-server#deploy-the-endpoint) +- [Plugin authentication](https://developers.openai.com/plugins/build/auth) +- [MCP plugin submission](https://developers.openai.com/plugins/deploy/submission#mcp) + +### Microsoft Foundry + +Status: research direction and integration guidance only. No Foundry-to-recon +managed remote MCP path has yet passed this plan's provider promotion gate. + +Foundry agents can consume public remote MCP endpoints. Microsoft's current +private-MCP guidance is more specific: use Standard Agent Setup and host the +server on Azure Container Apps with internal-only ingress in a dedicated MCP +subnet. Foundry's comparison says Container Apps supports any Linux-container +language and dependencies, while Functions requires platform-specific files, +is stateless, uses key auth by default, and needs API Management for OAuth. + +Plan: + +1. Prefer Container Apps for recon. +2. Use public ingress plus an application bearer only for a trusted-client + proof. +3. Use internal ingress for a private Foundry deployment. +4. Put API Management or another verified OAuth resource-server gateway in + front for external per-user clients. +5. Create Azure IaC only after a subscription, region, resource group, quota, + identity design, and cost approval are supplied. The Azure preparation gate + forbids guessing those values. + +Primary sources: + +- [Foundry remote MCP endpoints](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/model-context-protocol) +- [Build a Foundry MCP server](https://learn.microsoft.com/en-us/azure/foundry/mcp/build-your-own-mcp-server) +- [Azure Container Apps Well-Architected guidance](https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-container-apps) +- [Container Apps authentication](https://learn.microsoft.com/en-us/azure/container-apps/authentication) + +### AWS Bedrock and other AWS agents + +Status: research direction and integration guidance only. No AgentCore-hosted +or Bedrock-to-recon managed path has yet passed the provider promotion gate. + +AgentCore can both host an MCP server and connect agents to remote MCP. Its +runtime is model-flexible, including Bedrock, Anthropic, Google, and OpenAI +models. AgentCore Gateway adds centralized authentication, observability, +session routing, and policy when those features are justified. + +The hosting and consuming choices should stay separable. A Bedrock agent can +call recon on Cloud Run, and a non-Bedrock AI client can call recon on +AgentCore, subject to authentication compatibility. + +Primary sources: + +- [Host MCP servers in AgentCore Runtime](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-mcp.html) +- [AgentCore Runtime hosting](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agents-tools-runtime.html) +- [AgentCore MCP server targets](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-MCPservers.html) +- [AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) + +### Google agents and generic MCP clients + +Status: integration guidance only. The local MCP handshake is checked, but no +managed Vertex or other hosted client path has yet passed the provider +promotion gate. + +Google Cloud has first-party Cloud Run guidance for hosting remote MCP servers +and for IAM or OIDC client authentication. Vertex Agent Engine is an agent +runtime, not a replacement for the recon service. Use its framework's MCP +client or a small agent-side adapter when the selected Vertex surface does not +accept an arbitrary remote MCP URL directly. + +Any other MCP client can use the same endpoint if it supports Streamable HTTP +and the selected authentication method. A client that only supports stdio +should continue to launch local `recon mcp` rather than adding a fragile remote +bridge by default. + +Primary sources: + +- [Host MCP servers on Cloud Run](https://docs.cloud.google.com/run/docs/host-mcp-servers) +- [Vertex AI Agent Engine](https://cloud.google.com/vertex-ai/generative-ai/docs/reasoning-engine/overview) + +## Compute Provider Plans + +### 1. Portable OCI container + +Status: draft artifact, locally built and smoke-tested but not yet +cloud-provider-validated. See [deploy/container](../deploy/container/README.md). + +This is the common artifact for Cloud Run, Container Apps, ordinary container +services, and future AgentCore support. It uses digest-pinned build inputs, +locked Python dependencies, a non-root user, a process health check, and no +embedded secret. + +Acceptance before publishing a prebuilt image: + +- Build and smoke-test `linux/amd64` and `linux/arm64`. +- Generate an image SBOM and vulnerability scan. +- Sign the multi-architecture image and publish provenance using the same + identity discipline as Python releases. +- Prove wheel and container versions match. +- Document base-image refresh ownership and supported tag retention. + +Until that gate passes, operators build the image from a reviewed revision. + +### 2. Google Cloud Run + +Status: draft Terraform artifact, locally checked but not yet applied or +validated through a real Cloud Run ingress. See +[deploy/gcp-cloud-run](../deploy/gcp-cloud-run/README.md). This is the +directional first portable serverless path. + +Why first: + +- Google now documents remote MCP hosting on Cloud Run directly. +- It runs the current Python container without a function adapter. +- It supports response streaming, scale to zero, bounded maximum instances, + Secret Manager, service identities, probes, and requests up to 60 minutes. +- The recommended starting concurrency of eight matches a cautious first + profile for recon's I/O-heavy resolver. + +The draft module expresses two authentication modes: + +- `application-bearer`: public Cloud Run invoker, application authentication + from a numeric Secret Manager version. Use for AI-of-choice interoperability. +- `google-iam`: private Cloud Run invoker and named IAM members. Use only when + every client can obtain a Google ID token. + +Not yet included: + +- OAuth, Cloud Armor, a global load balancer, custom domain, VPC egress, shared + cache, or per-user quotas. +- Artifact Registry creation and image build pipeline. +- A project-wide logging or monitoring stack. + +Promotion gate: + +- One operator validates a real client handshake and bounded load test. +- p50 and p95 cold and warm latency, error rate, instance count, and cost per + 1,000 representative calls are recorded without target identities. +- Token rotation and immutable-image rollback are rehearsed. +- Cloud logs use an approved retention period and access policy. + +### 3. AWS Bedrock AgentCore Runtime + +Status: research direction and preferred AWS-specific design, plan only. + +AgentCore is the strongest conceptual AWS fit in July 2026. It is a secure, +serverless, purpose-built host for MCP servers. AWS recommends stateless mode +for basic MCP servers and requires an ARM64 container listening on +`0.0.0.0:8000/mcp`. Its inbound authorizer validates JWT issuer, audience, +client, scopes, and custom claims. The official MCP guide uses Auth0 because +Dynamic Client Registration works with MCP clients. + +Planned artifact: + +- ARM64 build of the shared container, with port 8000. +- ECR repository with immutable tags and scan-on-push. +- Least-privilege runtime role limited to that repository and required logs. +- `MCP` protocol, stateless mode, public network egress for recon's documented + sources, and a complete JWT authorizer. +- Runtime and endpoint outputs plus an MCP Inspector smoke test. +- Optional AgentCore Gateway only when policy, aggregation, or centralized + observability is actually required. + +Current stop rule: + +AWS requires MMDSv2 for AgentCore invocations starting 2026-06-30. The +official Terraform runtime resource available during this review exposes the +runtime and custom JWT authorizer but does not expose the required metadata +configuration. The current CloudFormation runtime schema reviewed on the same +date also omits it. Do not check in IaC that needs an imperative post-apply +patch or silently creates a runtime that cannot be invoked. Implement this +reference when the official provider or stable CDK path can express and retain +MMDSv2 declaratively, then validate it in an AWS account. + +Sources: + +- [AgentCore MCP protocol contract](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-mcp-protocol-contract.html) +- [AgentCore inbound JWT authorizer](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/inbound-jwt-authorizer.html) +- [AgentCore security best practices](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-security-best-practices.html) +- [Terraform AgentCore runtime resource](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/bedrockagentcore_agent_runtime) +- [CloudFormation AgentCore runtime resource](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-bedrockagentcore-runtime.html) + +### 4. AWS Lambda, App Runner, and ECS + +Status: research directions for secondary AWS paths, plan only. + +Lambda is viable with an OCI image and AWS Lambda Web Adapter. Use a Function +URL only for a small trusted-client deployment. Function URL authentication is +limited to IAM or none. Claude and OpenAI do not generally sign SigV4 requests, +so interoperable use means a publicly invokable URL with application auth, or +API Gateway with a JWT authorizer. Configure reserved concurrency, a timeout +near 180 seconds, immutable ECR image digest, and the adapter's readiness path. + +Lambda is not first because it adds an HTTP-to-invocation adapter, has more cold +start sensitivity, and complicates OAuth and streaming. It becomes worthwhile +only if the operator already standardizes on Lambda and accepts those limits. + +App Runner is a simpler long-running container endpoint but does not offer the +same scale-to-zero economics. ECS Fargate is appropriate for steady or highly +controlled traffic, VPC egress, and organization-standard load balancers. Both +should use an OAuth-capable gateway for per-user external access. + +Sources: + +- [Lambda container images](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html) +- [Lambda timeouts](https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html) +- [Function URL authentication](https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html) +- [AWS Lambda Web Adapter](https://github.com/aws/aws-lambda-web-adapter) +- [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) + +### 5. Azure Container Apps and Functions + +Status: research direction for the preferred Azure design. IaC is deferred +until an Azure context and explicit plan approval exist. + +Container Apps is preferred because recon already is a Linux container, needs +ordinary Python dependencies, and can use HTTP ingress, scale to zero, managed +identity, Key Vault, probes, and Azure Monitor. A production Bicep plan should +include: + +- Resource group and region chosen by the operator. +- Container Apps environment and Log Analytics or approved Azure Monitor path. +- User-assigned managed identity with only Key Vault secret read access. +- External or internal ingress to port 8080, based on the consumer. +- Minimum replicas zero, bounded maximum replicas, HTTP concurrency scaling, + 180-second end-to-end timeout validation, and `/health` probes. +- Key Vault backed static bearer for the initial public proof, or API Management + and Entra-compatible OAuth for per-user external access. +- Internal-only ingress and a dedicated MCP subnet for private Foundry use. +- Budgets, alerts, log retention, and image-digest rollback. + +Azure Functions is a secondary path. Microsoft's current Foundry guidance says +it requires Functions-specific root files, supports only stateless servers, uses +key authentication by default, and needs API Management for OAuth. It offers no +advantage over Container Apps for the current package unless an operator has a +strong Functions standard. + +Stop rule: do not invent a subscription, resource group, region, quota, Entra +tenant, or monitoring workspace. Generate and validate Azure IaC only after an +operator selects those values and approves the Azure deployment plan. + +### 6. Cloudflare Workers and Containers + +Status: research direction only, watching platform maturity. + +For a new TypeScript MCP server, Cloudflare's current best practice is the +stateless `createMcpHandler`; the older `McpAgent` path is deprecated. Workers +can integrate Cloudflare Access or a third-party OAuth provider and are a strong +edge option for lightweight tools. + +recon is a Python application with resolver, XML, graph, and catalog +dependencies, so a direct Workers rewrite would create a second implementation +and threaten output parity. Cloudflare Containers can run arbitrary OCI images +and scale to zero, but the July 2026 platform still documents explicit container +instance routing and manual pool logic while built-in autoscaling remains a +future capability. That is not yet a better reference than Cloud Run. + +Planned revisit: + +- Keep the Python container unchanged behind a small Worker authentication and + routing boundary. +- Require managed autoscaling, stable request routing, health probes, secret + integration, and local emulator parity before IaC is added. +- Do not port recon logic to TypeScript without an independently valuable use + case and cross-language golden-output proof. + +Sources: + +- [Cloudflare remote MCP guide](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) +- [Cloudflare MCP handler API](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) +- [Cloudflare Containers](https://developers.cloudflare.com/containers/) +- [Container scaling and routing](https://developers.cloudflare.com/containers/platform-details/scaling-and-routing/) + +### 7. Kubernetes, Knative, and other OCI platforms + +Status: research direction for generic compatibility, with no maintained IaC. + +Kubernetes is appropriate only when the operator already runs a supported +cluster or when sustained scale, custom egress, regional topology, or policy +needs exceed a managed container service. A future Helm chart would require: + +- Deployment by immutable digest, non-root and read-only filesystem settings. +- ClusterIP service, ingress or Gateway API, TLS, and OAuth proxy. +- Startup, readiness, and liveness probes. +- Horizontal Pod Autoscaler with explicit minimum and maximum replicas. +- Pod disruption budget, topology spread, resource requests and limits. +- NetworkPolicy allowing DNS and the documented HTTPS destinations. +- External secret integration, per-identity rate limits, logs, metrics, and + rollback tests. + +Knative, Fly.io, Render, Railway, DigitalOcean App Platform, Oracle Container +Instances, and similar services can use the same container contract. They do +not each need project-maintained IaC. Add a provider-specific reference only +after a named user supplies a maintained platform need and can help validate +it. + +## Threat Model and Operational Requirements + +The remote path changes accessibility, not recon's collection boundary. It +also adds risks that do not exist in a single-user stdio process. + +| Risk | Initial control | Required growth control | +|---|---|---| +| Stolen shared token | Secret manager, TLS, no access log, rotation | Short-lived per-user OAuth and revocation | +| Cost amplification, including public requests rejected only after instance startup | Maximum instances, low concurrency, request cap, budgets and alerts | Per-subject quotas and an ingress or gateway rate limit | +| Cross-user state | Stateless MCP and no remote mutation tools | Separate tenant boundary before multi-organization use | +| Browser DNS rebinding or cross-origin calls | Exact Host option and Origin denied by default | Reviewed CORS policy only for a named browser client | +| Prompt injection in observed DNS or certificate text | Existing untrusted-observed-content instruction and output sanitization | Client approvals and model-side data treatment | +| Sensitive query logs | Access log disabled; documented application-log risk | Domain redaction mode, restricted sinks, short retention | +| Supply-chain drift | Locked Python graph and digest-pinned build inputs | Signed multi-architecture image, SBOM, provenance and scans | +| Provider outage or cold start | Bounded timeouts and retry-safe read-only calls | Measured SLO, warm minimum only when justified | +| Token or identity confused deputy | Exact audience and issuer in OAuth design | Scopes, subject quotas, resource indicators and audit | + +Remote deployments must preserve recon's public-metadata, passive-in-scope +language. Scaling a lookup does not turn it into active scanning, a security +verdict, or proof of organization ownership. + +## Validation Plan + +Current provider-validation status: none. The checks below the "In repository" +heading establish draft artifact quality only. They are not evidence that a +Cloud Run, AWS, Azure, Cloudflare, Kubernetes, or OAuth deployment works in a +real account. + +### In repository + +- Ruff and strict Pyright cover the remote adapter. +- Unit tests cover configuration failure, static bearer authentication, + trusted platform mode, Host and Origin policy, health, body bounds, security + headers, and remote tool filtering. +- CI validates Terraform formatting and schema with its locked provider. +- CI builds the container, starts it, checks health, and proves unauthenticated + MCP requests return 401. +- The full existing suite retains branch coverage above 80 percent and the + repository's stricter 90.2 percent CI floor. + +### Before a provider reference is promoted + +1. Validate IaC against the exact current provider version. +2. Produce a no-change second plan after apply. +3. Prove unauthenticated, malformed, oversize, and disallowed-origin requests + fail before tool dispatch. +4. Initialize with MCP Inspector, list tools and resources, and call a reserved + synthetic namespace. +5. Confirm remote mutation tools are absent. +6. Run concurrent cold and warm calls through the real ingress. +7. Record aggregate latency, errors, instance count, and cost without target + names or per-domain rows. +8. Rotate credentials and roll back the image. +9. Confirm deletion protection and a reviewed destroy plan. +10. Recheck every first-party source and platform constraint at implementation + time because cloud services and AI connector contracts change quickly. + +## Priority and Delivery Sequence + +This is roadmap track 4, below the three core priorities. + +| Order within optional track | Work | State | +|---|---|---| +| 4.0 | Shared authenticated stateless remote adapter and OCI container | Draft artifact, locally checked only | +| 4.1 | Cloud Run Terraform with two auth modes and CI structural checks | Draft artifact, not yet provider-validated | +| 4.2 | One operator proof, load and cost characterization, token rotation | Awaiting external operator context | +| 4.3 | Shared OAuth 2.1 gateway contract | Planned, gated by a real per-user client | +| 4.4 | AWS AgentCore IaC | Planned, blocked on declarative MMDSv2 coverage and account validation | +| 4.5 | Azure Container Apps Bicep or Terraform | Planned, blocked on subscription, region, quota, and plan approval | +| 4.6 | Cloudflare container route or Kubernetes chart | Deferred until named demand and platform gates exist | +| 4.7 | Public directory submission or project-operated service | Not planned | + +The stop rule is simple: optional cloud work pauses whenever it would displace +a higher roadmap priority, weaken local use, duplicate recon's engine, expose +an unauthenticated endpoint, or claim provider support that has not passed a +real deployment validation. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9323a414..60f95ac6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,9 @@ tracked separately from product work. > release path. The product is not "finished." The active work is to make every > default claim evidence-tight, prove that advanced inference adds user value, > characterize MCP v2 compatibility, and make latency, degradation, catalog -> quality, and agent context cost measurable. +> quality, and agent context cost measurable. A separate fourth track provides +> optional operator-hosted access and bounded scale-out without changing the +> local default or creating a project-operated service. > > **Code-graph orientation:** refresh the ignored > `.agent/codegraph/manifest.json` after each tracked milestone and read it for @@ -33,8 +35,9 @@ start until priority 1 produces a claim taxonomy. | Track | Why it sits here | State today | What closes it | |---|---|---|---| | [1. Evidence-semantic integrity](#1-restore-evidence-semantic-integrity) | Truthfulness outranks features, and this defect class is still surfacing one case at a time rather than being swept. The most recent instance let a queried domain report a related domain's email controls while its own DMARC policy stayed null in the same record. | One machine-readable claim contract exists, `dns.dmarc.valid_policy_is_reject.v1`. Every other material default claim rests on review and regression tests. | Every default insight, label, MCP description, recommendation, and score has a direct evidence-to-claim path, and explanations report provenance completeness instead of implying it. | -| [2. MCP protocol characterization](#2-characterize-mcp-v2-beta-compatibility-before-2026-07-28) | The only externally timed track. The 2026-07-28 specification is a breaking release and the SDK moves regardless of recon, so deferring costs more than doing it. | The exact `1.28.1` and `2.0.0b1` matrix passed 2026-07-13 and CI keeps both pins blocking. The SDK published `2.0.0b2` on 2026-07-14, so the characterized candidate is one release behind. | A dated matrix over the current candidate and then the final specification with the stable v2 SDK, with deterministic ordering and conforming schemas, before the production `<2` pin moves. | +| [2. MCP protocol characterization](#2-keep-final-mcp-v2-compatibility-green-before-adoption) | The 2026-07-28 specification is a breaking release and the SDK moves regardless of recon, so compatibility must stay explicit. | The exact stable `1.28.1` and `2.0.0` matrix passed 2026-07-28 and CI keeps both pins blocking. | Keep deterministic ordering, conforming schemas, live stdio behavior, and both exact stable pins green; treat production v2 adoption as a separate release decision. | | [3. Product-quality baseline](#3-establish-a-reproducible-product-quality-baseline) | Depends on the claim taxonomy from priority 1. Measuring claim families before they are defined measures something about to be redefined. | Specified, not started. Extensive process evidence exists; product-outcome evidence does not. | A dated aggregate-safe scorecard with a decision rule written before the run, deciding whether advanced fusion stays primary or becomes an advanced diagnostic. | +| [4. Optional cloud access and scale-out](#4-optional-operator-hosted-access-and-scale-out) | Useful accessibility and scale polish for some operators, but lower priority than the three core evidence and compatibility tracks. | Draft stateless remote adapter, container, and Cloud Run Terraform pass local artifact checks but are not yet provider-validated. Local remains the default. | One operator proof plus bounded load, cost, rotation, retention, and rollback evidence. Each additional provider needs named demand and its own validation context. | Everything blocked behind these, and the gate that unblocks each, is in [ROADMAP.md](../ROADMAP.md#what-is-deliberately-not-next). The phased execution @@ -70,7 +73,7 @@ current debt without turning every refinement into feature work. | Debt class | Current state | Next boundary | |---|---|---| -| Feature work | Governed by the dependency order below, not by the polish loop | Do not add commands, schemas, hosted surfaces, or inference modes without their existing evidence gate | +| Feature work | Governed by the dependency order below, not by the polish loop; the optional operator-hosted surface now has a named architecture and security gate | Do not add commands, schemas, provider claims, or inference modes without their existing evidence gate | | UX flow | Root help, no-argument onboarding, malformed-input recovery, all-source failure recovery, low-confidence next steps, batch outcome guidance, cross-platform release verification, target-free catalog discovery, and explicit bounded-versus-complete cache inspection are implemented | Specify batch all-error exit semantics before considering an opt-in strict mode | | Visual polish | Lookup and batch help use task panels; linear help and adaptive welcome rows keep commands complete; fingerprint previews, ranked signal results, and narrow cache rows keep hierarchy and field association without changing structured order | Preserve complete option visibility and exact technical-token copyability before changing presentation metadata | | Observability | MCP rejection logs and unexpected batch details stay bounded; live MCP diagnostics retain completed rows and name the failed protocol phase; cache overview names exact inspected, uninspected, failed, and temporary-artifact state; corpus tests separate collection errors from negative observations; captured gate logs are plain; remote readiness and release recovery name exact evidence and preconditions | Define a versioned doctor or cache record only after a machine consumer and compatibility envelope are named | @@ -190,44 +193,40 @@ Acceptance evidence: Stop rule: do not add new inference or scoring semantics while a known default claim lacks adequate evidence. -### 2. Characterize MCP v2 beta compatibility before 2026-07-28 +### 2. Keep final MCP v2 compatibility green before adoption -Status: candidate checkpoint complete on 2026-07-13. The exact v1.28.1 and -v2.0.0b1 matrix passes; final specification and stable-v2 adoption remain -pending. +Status: stable compatibility checkpoint complete on 2026-07-28. The exact +v1.28.1 and v2.0.0 matrix passes; production adoption remains a separate +release decision. -Open gap: the SDK published `2.0.0b2` on 2026-07-14, the day after that -checkpoint, so the pinned candidate in the CI matrix is one prerelease behind -the current beta. Re-run the characterization against the current candidate -before the final specification lands, so the final gate diffs against a current -result rather than a stale one. The matrix pin lives in -`.github/workflows/ci.yml` and the probe is -`scripts/check_mcp_compatibility.py`. +The matrix pin lives in `.github/workflows/ci.yml` and the probe is +`scripts/check_mcp_compatibility.py`. It exercises both stable SDK generations +without changing `pyproject.toml`, `uv.lock`, or the active environment. Why second: the final MCP 2026-07-28 specification and stable Python SDK are -time-bound external changes. Production remains on the stable v1 SDK line until -the final specification and stable v2 SDK pass recon's full gate. +external compatibility boundaries. Production remains on the stable v1 SDK +line until an explicit adoption review changes that decision. -Completed checkpoint: +Completed checkpoints: -- Exact-pin `mcp==2.0.0b1` in an isolated compatibility environment without - publishing or widening the production dependency to a prerelease. +- Exact-pin `mcp==2.0.0` in an isolated compatibility environment without + widening the production dependency. - Exercise server import, stdio startup, `recon mcp doctor`, discovery, tool calls, resource reads, structured output, errors, and deterministic listing - under v1.28.1 and the v2 beta. + under stable v1.28.1 and stable v2.0.0. - Record a migration result for `FastMCP`, protocol types, `ToolError`, annotations, discovery, wire aliases, and synchronous resource handlers. - Review shared catalog and cache behavior under the v2 worker-thread model. - Reject the unproven `mcp>=1.0` floor and raise it to the fully characterized stable v1.28.1 release. -The same compatibility boundary now passes 22 tools, five resources, zero +The same compatibility boundary passes 22 tools, five resources, zero resource templates, one prompt, 44 schema documents, representative structured success and error results, concurrent catalog reloads, real stdio calls, and -the live doctor on both supported exact pins. Candidate v2 additionally proves +the live doctor on both supported exact pins. Stable v2 additionally proves `server/discover`, worker-thread synchronous handlers, and conservative complete-result metadata on every cacheable method. Production remains on -`mcp>=1.28.1,<2` until the final gate. +`mcp>=1.28.1,<2` until a separate adoption review changes it. Acceptance evidence: @@ -237,12 +236,12 @@ Acceptance evidence: generations. - Every complete `server/discover`, `tools/list`, supported resource-list, and resource-read result carries valid `ttlMs` and `cacheScope` hints as required - by the draft caching specification. + by the final caching specification. - The local stdio workflow remains intact. -- Production stays on `<2` until the stable v2 SDK and final specification pass - the full gate. -- Remote HTTP, OAuth, Roots, Sampling, Apps, Tasks, and protocol logging are not - added without a named product need and a separate architecture review. +- Production stays on `<2` until a separate adoption review changes it. +- The optional remote HTTP need now has a separate architecture and security + review. It does not imply OAuth, Roots, Sampling, Apps, Tasks, protocol + logging, or production SDK v2 adoption. Detailed work and rollback criteria live in [mcp-2026-07-28-readiness.md](mcp-2026-07-28-readiness.md) and @@ -277,7 +276,7 @@ Work: metric it should improve and the regression budget it must preserve. - Run the stable-v1 resolver, allocation, CT-value, and schema characterization before completing the scorecard. It supplies performance inputs to this - priority; only candidate-SDK deltas wait for the MCP v2 matrix. + priority; apply stable-v2 deltas from the completed MCP matrix separately. Primary evaluation design: @@ -397,11 +396,76 @@ Acceptance evidence: Stop rule: do not expand graph or probabilistic machinery without measured benefit to a named user outcome. +### 4. Optional operator-hosted access and scale-out + +Status: draft and not yet provider-validated, lower priority than the three +core tracks above. It is intended to be directionally useful, not a validated +production deployment. + +Why fourth: an authenticated remote endpoint can make recon accessible to +operators who want to use it from several AI products, shared automation, or a +cloud environment. That is useful depth and scale polish for some users. It is +not required to use recon, does not replace the local CLI or stdio MCP server, +and does not create a project-operated hosted service. +The project does not operate a hosted endpoint. + +Principles: + +- Keep one model-neutral remote MCP boundary. OpenAI, Anthropic, Microsoft + Foundry, and other compatible clients consume that endpoint; they do not need + separate recon implementations. +- Keep deployment operator-owned and opt-in. Local CLI and stdio MCP remain the + complete default and require no cloud account. +- Use a stateless Streamable HTTP process in a non-root OCI container, with + bounded requests, explicit authentication, host and origin controls, no + stateful catalog-mutation tools, and provider-managed secret storage. +- Treat a cloud provider as a hosting and identity choice, not as the AI model + choice. A caller can host on one provider and use an AI client from another. +- Label every reference at the evidence level it has. Passing local syntax, + build, and protocol checks is not provider validation. A provider logo or + speculative Terraform module is not implementation evidence. + +Initial work: + +- Maintain the optional remote adapter and portable container as draft + artifacts without adding a new default CLI path or dependency group. +- Maintain one draft Google Cloud Run Terraform reference because Cloud Run + documents remote MCP hosting, supports scale to zero, and accepts the + portable container contract. +- Keep AWS AgentCore, Azure Container Apps, Cloudflare, Kubernetes, and + per-user OAuth as researched plans until each has named demand and the + provider-specific validation context needed to make its security and IaC + claims true. + +Acceptance evidence: + +- One external operator validates the reference through a real remote MCP + client and records the exact image digest, region, identity mode, and rollback + path. +- Bounded load testing records concurrency, latency, timeout, scale-to-zero, + provider quota, and cost behavior without committing queried-domain data. +- Credential rotation, secret versioning, log redaction and retention, image + rollback, and deletion are exercised. +- CI keeps the container build, health boundary, unauthenticated rejection, and + Terraform formatting and validation green. +- Adding another cloud implementation requires a named operator, provider + identity and region context, and the same evidence at that platform's + boundary. + +The full architecture, July 2026 research, provider matrix, threat model, +sequencing, and runbook gates live in +[optional-cloud-deployment-plan.md](optional-cloud-deployment-plan.md). + +Stop rule: do not turn this into a project-operated SaaS, claim model-vendor +hosting where the vendor is only an MCP client, or add provider IaC that has not +been validated against a real provider context. + ## Next These tracks follow the top three in dependency order. The stable-v1 portion of the async and schema characterization is a supporting input to priority 3 and -runs before its scorecard; only candidate-SDK deltas wait for priority 2. +runs before its scorecard; stable-v2 deltas are available from the completed +priority 2 matrix. ### Separate observation change from interpretation change @@ -890,8 +954,11 @@ generated-artifact drift gates. - Active scanning, port enumeration, vulnerability or exploit testing. - Credentialed tenant or SaaS enumeration. -- Remote hosted MCP, OAuth, or multi-tenant service operation without a named - consumer, threat model, and architecture review. +- A project-operated public endpoint, SaaS, or multi-tenant service. The + optional references are operator-owned deployments only. +- Per-user OAuth or additional provider implementations without the named + consumer, threat model, identity context, and validation gates in the + optional cloud plan. - Company ownership, firmographics, news, financial, or hiring inference. - Security verdicts, certifications, confirmed-vulnerability claims, or claims about controls that are not publicly observable. @@ -901,11 +968,10 @@ generated-artifact drift gates. ## Current External Basis -Checked through 2026-07-14 against primary sources and recent research: +Checked through 2026-07-28 against primary sources and recent research: - [MCP 2026-07-28 release candidate](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) -- [MCP draft tools specification](https://modelcontextprotocol.io/specification/draft/server/tools) -- [MCP draft caching specification](https://modelcontextprotocol.io/specification/draft/server/utilities/caching) +- [MCP current documentation](https://modelcontextprotocol.io/docs/getting-started/intro) - [MCP Python SDK release history](https://pypi.org/project/mcp/) - [RFC 9989: DMARC](https://www.rfc-editor.org/info/rfc9989/) - [RFC 3986: URI generic syntax](https://www.rfc-editor.org/info/rfc3986/) diff --git a/docs/strategic-gap-audit.md b/docs/strategic-gap-audit.md index 58897792..4ffdfd2d 100644 --- a/docs/strategic-gap-audit.md +++ b/docs/strategic-gap-audit.md @@ -3,7 +3,7 @@ Status: source-backed step-back audit for the current roadmap. This file does not add CLI, MCP, JSON, fingerprint, schema, dependency, or network behavior. -Checked: 2026-07-17. +Checked: 2026-07-28. ## Bottom Line @@ -17,8 +17,8 @@ and MCP context and compatibility cost. The highest-value work is not runtime expansion. It is correcting any default claim that is stronger than its public evidence and establishing an aggregate-safe product-quality baseline before adding more inference or graph -surface. The completed MCP candidate matrix now remains a blocking regression -and final-adoption gate. Artifact review, OpenSSF process, independent +surface. The completed stable MCP matrix now remains a blocking regression +gate, with production adoption separate. Artifact review, OpenSSF process, independent replication, and archive work remain worthwhile maintainer tracks, but they do not outrank product truthfulness or measured user value. @@ -44,10 +44,9 @@ not outrank product truthfulness or measured user value. , , and -- MCP 2026-07-28 release candidate, draft tools, and Python SDK history: +- MCP 2026-07-28 release candidate, current documentation, and Python SDK history: , - , - , and + , and - Python asyncio development guidance: @@ -107,8 +106,8 @@ not outrank product truthfulness or measured user value. - GitHub contributor history and current contributors are maintainer-only. - Top-level dependencies are current under the locked resolver state. MCP is intentionally bounded to `>=1.28.1,<2`; the exact isolated matrix passes on - stable v1.28.1 and candidate v2.0.0b1, while final v2 adoption remains - contingent on the final specification, stable SDK, and full release gate. + stable v1.28.1 and stable v2.0.0, while production v2 adoption remains a + separate release decision. - Public DMARC references in comments, tests, and validation notes use the current RFC 9989 protocol specification and RFC 9990 aggregate-reporting split rather than the prior obsolete citation. @@ -130,10 +129,10 @@ These are not active gaps for the current roadmap: | Gap | Why it matters | Current state | Next action | Stop rule | |---|---|---|---|---| | Evidence-semantic integrity | Derived observations and model-bound public-evidence values can be presented more strongly than their evidence supports. | Parent-platform child-product inference, MCP score wording, and cross-renderer provider drift are corrected; remaining default claims still need a complete provenance audit. | Audit every default claim and correct the smallest evidence-to-claim paths while preserving stable JSON. | Do not add new inference semantics while a known default claim lacks direct provenance. | -| MCP v2 compatibility | The final 2026-07-28 protocol and stable SDK are imminent and contain breaking changes. | The exact v1.28.1 and v2.0.0b1 matrix passes; one compatibility boundary, the truthful dependency floor, doctor discovery selection, and conservative cache hints are implemented. | Keep the matrix blocking, then rerun it against the final specification and stable v2 SDK before changing production. | Do not publish a prerelease dependency or add remote MCP scope. | +| MCP v2 compatibility | The final 2026-07-28 protocol and stable SDK contain breaking changes that must remain characterized. | The exact stable v1.28.1 and v2.0.0 matrix passes; one compatibility boundary, the truthful dependency floor, doctor discovery selection, and conservative cache hints are implemented. | Keep both stable pins blocking and make production adoption a separate release decision. | Do not couple production adoption or remote MCP scope to compatibility maintenance. | | Measured product utility | Green gates and sophisticated models do not establish that the output improves an operator decision. | No unified scorecard covers unsupported claims, abstention, provenance, catalog surface, CT marginal value, latency, degradation, or MCP context cost. | Establish an aggregate-safe baseline and predeclared deterministic-versus-fusion ablation. | Do not expand graph or probabilistic machinery without measured benefit. | | Catalog quality and freshness | A large catalog can grow coverage and false positives at the same time. | The catalog has 855 entries and 1,062 detections. One frozen convenience-sample baseline covers every bounded path, and a 366-namespace unseen vertical holdout exercised every new rule without post-holdout tuning. The legacy date backlog and independent rank and regional strata remain open. | Add rank and regional rounds, backfill dates in reviewed families, and ratchet stale dates and negative fixtures. | No new undated or untested rule. No population claim from the convenience sample and no broad coverage claim while a bounded path or named stratum is unmeasured. | -| Latency and degradation contract | CT and external providers dominate long tails, while current published measurements are historical single runs. | Timeouts and partial results are bounded, but stage measurements and reproducible p50/p95 budgets are not established. | Run stable-v1 resolver and schema characterization before the product scorecard; apply only candidate-SDK deltas after the MCP matrix. | Move only proven blocking I/O and do not create brittle timing CI. | +| Latency and degradation contract | CT and external providers dominate long tails, while current published measurements are historical single runs. | Timeouts and partial results are bounded, but stage measurements and reproducible p50/p95 budgets are not established. | Run stable-v1 resolver and schema characterization before the product scorecard; apply stable-v2 deltas from the completed MCP matrix. | Move only proven blocking I/O and do not create brittle timing CI. | | OpenSSF Best Practices Badge | Scorecard marks this as absent until a real badge project exists. | Readiness evidence and the manual answer queue are documented in [openssf-posture.md](openssf-posture.md) and [openssf-badge-readiness.md](openssf-badge-readiness.md), but no badge is claimed. | Complete the questionnaire on `bestpractices.dev`, then link the real badge page. | Do not add a placeholder badge or claim a badge before the project exists. | | Reviewed PR signal | Scorecard cannot credit review history on direct-main work. | CODEOWNERS exists and required checks protect main. | Use reviewed PRs for non-urgent work when another qualified reviewer is available. | Do not manufacture review history or contributor diversity. | | Artifact archive and DOI | External papers are easier to cite and review when the exact artifact is archived. | GitHub release, PyPI release, citation metadata, SBOM, provenance, a bounded same-job deterministic-build recipe, and [archive-readiness.md](archive-readiness.md) exist; the archive path decision packet now separates `CITATION.cff` sufficiency from `.zenodo.json` need. | Once the paper package freezes, choose a DOI path such as Zenodo or the venue supplement, then add metadata deliberately. | Do not add `.zenodo.json`, DOI language, or archive-badge language before the archive policy is chosen. | @@ -168,10 +167,10 @@ release count, and feature count are supporting facts, not outcomes. 4. Complete the product-quality scorecard and freeze the ablation decision rule before running it. 5. Use the baseline to decide dimensioned email observations, catalog - priorities, and agent-surface simplification; apply candidate-SDK - characterization deltas after the MCP matrix. -6. Keep the MCP beta matrix blocking and repeat it against the final protocol - and stable v2 SDK before changing the production dependency. + priorities, and agent-surface simplification; apply stable-v2 SDK + characterization deltas from the completed MCP matrix. +6. Keep the exact stable MCP v1 and v2 matrix blocking; change the production + dependency only through a separate release decision. 7. Keep main clean, CI green, release readiness passing, and PyPI and GitHub release state and provenance aligned. 8. Run the paper claim freeze, OpenSSF questionnaire, outside replication, and diff --git a/scripts/check_mcp_compatibility.py b/scripts/check_mcp_compatibility.py index 83f4245b..7ce95ed6 100644 --- a/scripts/check_mcp_compatibility.py +++ b/scripts/check_mcp_compatibility.py @@ -30,7 +30,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] AGENT_ROOT = REPO_ROOT / ".agent" -DEFAULT_SDK_VERSIONS = ("1.28.1", "2.0.0b1") +DEFAULT_SDK_VERSIONS = ("1.28.1", "2.0.0") ProbeStatus = Literal["pass", "fail", "blocked", "not_applicable"] diff --git a/src/recon_tool/remote_server.py b/src/recon_tool/remote_server.py new file mode 100644 index 00000000..247eb5bf --- /dev/null +++ b/src/recon_tool/remote_server.py @@ -0,0 +1,420 @@ +"""Optional authenticated Streamable HTTP entry point for recon's MCP server. + +The supported product default remains the local stdio server. This module is a +small deployment adapter for operators who deliberately choose to run recon in +an authenticated container or managed runtime. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Literal, cast +from urllib.parse import urlsplit + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from recon_tool.mcp_client.sdk_compat import SDK_FAMILY +from recon_tool.server import mcp as default_mcp + +AuthMode = Literal["static-bearer", "trusted-platform"] + +_BODY_METHODS = frozenset({"PATCH", "POST", "PUT"}) +_DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024 +_MAX_REQUEST_BYTES = 16 * 1024 * 1024 +_MIN_BEARER_TOKEN_BYTES = 32 +_REMOTE_ONLY_USELESS_TOOLS = frozenset({"list_ephemeral_fingerprints"}) +_SECURITY_HEADERS = ( + (b"cache-control", b"no-store"), + (b"referrer-policy", b"no-referrer"), + (b"x-content-type-options", b"nosniff"), +) +_INVALID_CONTENT_LENGTH = (400, b'{"error":"invalid content length"}') +_REQUEST_TOO_LARGE = (413, b'{"error":"request too large"}') + + +class RemoteConfigurationError(ValueError): + """Raised when the optional remote process is not configured safely.""" + + +def _parse_int(name: str, value: str, *, minimum: int, maximum: int) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise RemoteConfigurationError(f"{name} must be an integer") from exc + if not minimum <= parsed <= maximum: + raise RemoteConfigurationError(f"{name} must be between {minimum} and {maximum}") + return parsed + + +def _parse_csv(value: str) -> tuple[str, ...]: + return tuple(item.strip() for item in value.split(",") if item.strip()) + + +def _validate_bind_host(value: str) -> str: + host = value.strip() + if not host or any(character.isspace() or ord(character) < 0x20 for character in host): + raise RemoteConfigurationError("RECON_REMOTE_HOST must be a non-empty host without whitespace") + return host + + +def _validate_allowed_hosts(value: str) -> frozenset[str]: + hosts = _parse_csv(value) + if any("/" in host or any(character.isspace() for character in host) for host in hosts): + raise RemoteConfigurationError("RECON_REMOTE_ALLOWED_HOSTS must contain exact host values") + return frozenset(host.casefold() for host in hosts) + + +def _validate_allowed_origins(value: str) -> frozenset[str]: + origins = _parse_csv(value) + for origin in origins: + parsed = urlsplit(origin) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise RemoteConfigurationError( + "RECON_REMOTE_ALLOWED_ORIGINS must contain exact HTTP or HTTPS origins without paths" + ) + return frozenset(origin.rstrip("/").casefold() for origin in origins) + + +def _validate_bearer_token(value: str | None) -> str: + if value is None or not value: + raise RemoteConfigurationError("RECON_REMOTE_BEARER_TOKEN is required for static-bearer mode") + if not value.isascii() or any(character.isspace() or ord(character) < 0x21 for character in value): + raise RemoteConfigurationError("RECON_REMOTE_BEARER_TOKEN must be an ASCII bearer value without whitespace") + if len(value.encode("ascii")) < _MIN_BEARER_TOKEN_BYTES: + raise RemoteConfigurationError( + f"RECON_REMOTE_BEARER_TOKEN must contain at least {_MIN_BEARER_TOKEN_BYTES} bytes" + ) + return value + + +@dataclass(frozen=True, slots=True) +class RemoteConfig: + """Validated process configuration for the optional remote entry point.""" + + auth_mode: AuthMode + bearer_token: str | None = field(repr=False) + bind_host: str + port: int + max_request_bytes: int + allowed_hosts: frozenset[str] + allowed_origins: frozenset[str] + + @classmethod + def from_environ(cls, environ: Mapping[str, str] | None = None) -> RemoteConfig: + values = os.environ if environ is None else environ + raw_auth_mode = values.get("RECON_REMOTE_AUTH_MODE", "static-bearer").strip().casefold() + if raw_auth_mode not in {"static-bearer", "trusted-platform"}: + raise RemoteConfigurationError("RECON_REMOTE_AUTH_MODE must be static-bearer or trusted-platform") + auth_mode = cast(AuthMode, raw_auth_mode) + + raw_token = values.get("RECON_REMOTE_BEARER_TOKEN") + if auth_mode == "static-bearer": + bearer_token = _validate_bearer_token(raw_token) + else: + if raw_token: + raise RemoteConfigurationError( + "RECON_REMOTE_BEARER_TOKEN must be unset when trusted-platform mode is selected" + ) + bearer_token = None + + bind_host = _validate_bind_host(values.get("RECON_REMOTE_HOST", "0.0.0.0")) # noqa: S104 + port = _parse_int( + "RECON_REMOTE_PORT", + values.get("RECON_REMOTE_PORT", "8080"), + minimum=1, + maximum=65535, + ) + max_request_bytes = _parse_int( + "RECON_REMOTE_MAX_REQUEST_BYTES", + values.get("RECON_REMOTE_MAX_REQUEST_BYTES", str(_DEFAULT_MAX_REQUEST_BYTES)), + minimum=1024, + maximum=_MAX_REQUEST_BYTES, + ) + + return cls( + auth_mode=auth_mode, + bearer_token=bearer_token, + bind_host=bind_host, + port=port, + max_request_bytes=max_request_bytes, + allowed_hosts=_validate_allowed_hosts(values.get("RECON_REMOTE_ALLOWED_HOSTS", "")), + allowed_origins=_validate_allowed_origins(values.get("RECON_REMOTE_ALLOWED_ORIGINS", "")), + ) + + +def _header_values(scope: Scope, name: bytes) -> tuple[str, ...]: + raw_headers = cast(list[tuple[bytes, bytes]], scope.get("headers", [])) + return tuple(value.decode("latin-1") for key, value in raw_headers if key.lower() == name) + + +def _single_header(scope: Scope, name: bytes) -> str | None: + values = _header_values(scope, name) + if len(values) != 1: + return None + return values[0] + + +def _authorized(scope: Scope, token_digest: bytes) -> bool: + authorization = _single_header(scope, b"authorization") + if authorization is None: + return False + scheme, separator, credential = authorization.partition(" ") + if separator != " " or scheme.casefold() != "bearer" or not credential: + return False + candidate_digest = hashlib.sha256(credential.encode("utf-8", errors="surrogatepass")).digest() + return hmac.compare_digest(candidate_digest, token_digest) + + +async def _read_bounded_body(receive: Receive, limit: int) -> tuple[bytes | None, int | None]: + parts: list[bytes] = [] + size = 0 + while True: + message = await receive() + if message["type"] == "http.disconnect": + return None, 400 + if message["type"] != "http.request": + continue + body = message.get("body", b"") + if not isinstance(body, bytes): + return None, 400 + size += len(body) + if size > limit: + return None, 413 + parts.append(body) + if not message.get("more_body", False): + return b"".join(parts), None + + +def _replay_receive(body: bytes) -> Receive: + delivered = False + + async def receive() -> Message: + nonlocal delivered + if delivered: + return {"type": "http.disconnect"} + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +def _secured_send(send: Send) -> Send: + async def secured(message: Message) -> None: + if message["type"] == "http.response.start": + headers = list(cast(list[tuple[bytes, bytes]], message.get("headers", []))) + existing = {name.lower() for name, _ in headers} + headers.extend(header for header in _SECURITY_HEADERS if header[0] not in existing) + message = {**message, "headers": headers} + await send(message) + + return secured + + +async def _send_response( + scope: Scope, + send: Send, + response: tuple[int, bytes], + *, + content_type: bytes = b"application/json", + extra_headers: tuple[tuple[bytes, bytes], ...] = (), +) -> None: + status, body = response + headers = [(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))] + headers.extend(extra_headers) + secured = _secured_send(send) + await secured({"type": "http.response.start", "status": status, "headers": headers}) + response_body = b"" if scope.get("method") == "HEAD" else body + await secured({"type": "http.response.body", "body": response_body}) + + +async def _guard_host_and_origin(scope: Scope, send: Send, config: RemoteConfig) -> bool: + host_values = _header_values(scope, b"host") + if len(host_values) != 1 or not host_values[0].strip(): + await _send_response(scope, send, (400, b'{"error":"invalid host"}')) + return False + if config.allowed_hosts and host_values[0].casefold() not in config.allowed_hosts: + await _send_response(scope, send, (421, b'{"error":"host not allowed"}')) + return False + + origin_values = _header_values(scope, b"origin") + if len(origin_values) > 1: + await _send_response(scope, send, (403, b'{"error":"origin not allowed"}')) + return False + if origin_values: + origin = origin_values[0].rstrip("/").casefold() + if origin not in config.allowed_origins: + await _send_response(scope, send, (403, b'{"error":"origin not allowed"}')) + return False + return True + + +async def _serve_health_if_requested(scope: Scope, send: Send) -> bool: + if scope.get("path") != "/health": + return False + if scope.get("method") not in {"GET", "HEAD"}: + await _send_response( + scope, + send, + (405, b'{"error":"method not allowed"}'), + extra_headers=((b"allow", b"GET, HEAD"),), + ) + return True + await _send_response(scope, send, (200, b"ok\n"), content_type=b"text/plain; charset=utf-8") + return True + + +async def _guard_authentication( + scope: Scope, + send: Send, + config: RemoteConfig, + token_digest: bytes | None, +) -> bool: + if config.auth_mode != "static-bearer": + return True + if token_digest is not None and _authorized(scope, token_digest): + return True + await _send_response( + scope, + send, + (401, b'{"error":"unauthorized"}'), + extra_headers=((b"www-authenticate", b"Bearer"),), + ) + return False + + +def _content_length_error(scope: Scope, limit: int) -> tuple[int, bytes] | None: + content_length_values = _header_values(scope, b"content-length") + if len(content_length_values) > 1: + return _INVALID_CONTENT_LENGTH + if not content_length_values: + return None + try: + content_length = int(content_length_values[0]) + except ValueError: + return _INVALID_CONTENT_LENGTH + if content_length < 0: + return _INVALID_CONTENT_LENGTH + if content_length > limit: + return _REQUEST_TOO_LARGE + return None + + +async def _bounded_request_receive( + scope: Scope, + receive: Receive, + send: Send, + limit: int, +) -> Receive | None: + method = cast(str, scope.get("method", "GET")).upper() + if method not in _BODY_METHODS: + return receive + + content_length_error = _content_length_error(scope, limit) + if content_length_error is not None: + await _send_response(scope, send, content_length_error) + return None + + body, body_error = await _read_bounded_body(receive, limit) + if body_error is not None or body is None: + status = 413 if body_error == 413 else 400 + detail = b'{"error":"request too large"}' if status == 413 else b'{"error":"invalid request body"}' + await _send_response(scope, send, (status, detail)) + return None + return _replay_receive(body) + + +class RemoteSecurityMiddleware: + """Fail-closed HTTP boundary for the optional remote MCP application.""" + + def __init__(self, app: ASGIApp, config: RemoteConfig) -> None: + self.app = app + self.config = config + self._token_digest = ( + hashlib.sha256(config.bearer_token.encode("ascii")).digest() if config.bearer_token is not None else None + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "lifespan": + await self.app(scope, receive, send) + return + if scope["type"] != "http": + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008, "reason": "unsupported"}) + return + if not await _guard_host_and_origin(scope, send, self.config): + return + if await _serve_health_if_requested(scope, send): + return + if not await _guard_authentication(scope, send, self.config, self._token_digest): + return + bounded_receive = await _bounded_request_receive( + scope, + receive, + send, + self.config.max_request_bytes, + ) + if bounded_receive is None: + return + await self.app(scope, bounded_receive, _secured_send(send)) + + +async def prepare_remote_mcp(mcp_app: Any) -> ASGIApp: + """Restrict one MCP application to explicit read-only tools and HTTP.""" + tools = await mcp_app.list_tools() + for tool in tools: + annotations = getattr(tool, "annotations", None) + is_explicitly_read_only = getattr(annotations, "readOnlyHint", None) is True + if not is_explicitly_read_only or tool.name in _REMOTE_ONLY_USELESS_TOOLS: + mcp_app.remove_tool(tool.name) + + mcp_app.settings.host = "0.0.0.0" # noqa: S104 + mcp_app.settings.json_response = True + mcp_app.settings.stateless_http = True + # RemoteSecurityMiddleware owns Host and Origin validation. The SDK's + # localhost-only defaults would reject managed-service hostnames. + mcp_app.settings.transport_security = None + return cast(ASGIApp, mcp_app.streamable_http_app()) + + +def build_remote_application(config: RemoteConfig, mcp_app: Any | None = None) -> ASGIApp: + """Build the optional remote ASGI application in a fresh server process.""" + if SDK_FAMILY != "v1": + raise RemoteConfigurationError( + "The optional remote adapter currently requires the supported MCP v1 production SDK" + ) + active_mcp = default_mcp if mcp_app is None else mcp_app + base_app = asyncio.run(prepare_remote_mcp(active_mcp)) + return RemoteSecurityMiddleware(base_app, config) + + +def main() -> None: # pragma: no cover - exercised through container smoke tests + """Run the optional remote MCP process.""" + import uvicorn + + config = RemoteConfig.from_environ() + application = build_remote_application(config) + uvicorn.run( + application, + host=config.bind_host, + port=config.port, + log_level="info", + access_log=False, + server_header=False, + ) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/test_documentation_semantic_contracts.py b/tests/test_documentation_semantic_contracts.py index 98a9a878..0821f890 100644 --- a/tests/test_documentation_semantic_contracts.py +++ b/tests/test_documentation_semantic_contracts.py @@ -56,6 +56,58 @@ def test_public_package_and_development_metadata_use_current_language() -> None: assert "\N{EM DASH}" not in description +def test_optional_cloud_docs_preserve_the_local_default_and_operator_boundary() -> None: + readme = " ".join(_read("README.md").split()) + short_roadmap = " ".join(_read("ROADMAP.md").split()) + roadmap = " ".join(_read("docs/roadmap.md").split()) + plan = " ".join(_read("docs/optional-cloud-deployment-plan.md").split()) + + for text in (readme, short_roadmap, roadmap, plan): + assert "optional" in text.lower() + assert "local" in text.lower() + assert "project does not operate" in text.lower() + assert "draft" in text.lower() + assert "not a validated production deployment" in text + + assert "lower priority than the three core" in roadmap + assert "A project-operated public SaaS or multi-tenant recon service is not planned." in plan + assert "Current provider-validation status: none." in plan + for maturity in ( + "Research direction", + "Draft artifact", + "Provider-validated reference", + "Production-proven", + ): + assert maturity in plan + for platform in ( + "Google Cloud Run", + "AWS Bedrock AgentCore Runtime", + "Azure Container Apps", + "Cloudflare Workers", + "Kubernetes", + "Anthropic and Claude", + "OpenAI and ChatGPT", + ): + assert platform in plan + + for artifact in ( + "src/recon_tool/remote_server.py", + "deploy/container/Dockerfile", + "deploy/gcp-cloud-run/main.tf", + ): + assert (ROOT / artifact).is_file() + + deployment_docs = { + "deploy/README.md": "not yet provider-validated or production-ready", + "deploy/container/README.md": "not a production-readiness claim", + "deploy/gcp-cloud-run/README.md": "has not yet been applied and validated", + } + for path, required in deployment_docs.items(): + text = " ".join(_read(path).split()) + assert "draft" in text.lower() + assert required in text + + def test_weak_area_guidance_does_not_promote_sparse_shapes_to_org_facts() -> None: weak_areas = " ".join(_read("docs/weak-areas.md").split()) @@ -150,9 +202,7 @@ def test_contributor_fingerprint_guidance_uses_current_schema_and_claims() -> No def test_claude_integration_docs_preserve_replay_and_ownership_boundaries() -> None: plugin = " ".join(_read("agents/claude-code/README.md").split()) - triage = " ".join( - _read("agents/claude-code/skills/recon-fingerprint-triage/SKILL.md").split() - ) + triage = " ".join(_read("agents/claude-code/skills/recon-fingerprint-triage/SKILL.md").split()) for required in ( "retained apex/root TXT, SPF, MX, NS, and CNAME observations", diff --git a/tests/test_remote_server.py b/tests/test_remote_server.py new file mode 100644 index 00000000..a249c83e --- /dev/null +++ b/tests/test_remote_server.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import httpx +import pytest +from starlette.types import Receive, Scope, Send + +import recon_tool.remote_server as remote_server +from recon_tool.remote_server import ( + RemoteConfig, + RemoteConfigurationError, + RemoteSecurityMiddleware, + build_remote_application, + prepare_remote_mcp, +) + +TOKEN = "a" * 48 + + +def _config(**overrides: Any) -> RemoteConfig: + values: dict[str, Any] = { + "auth_mode": "static-bearer", + "bearer_token": TOKEN, + "bind_host": "0.0.0.0", # noqa: S104 + "port": 8080, + "max_request_bytes": 1024, + "allowed_hosts": frozenset(), + "allowed_origins": frozenset(), + } + values.update(overrides) + return RemoteConfig(**values) + + +async def _echo_app(scope: Scope, receive: Receive, send: Send) -> None: + body = b"" + if scope["type"] == "http": + message = await receive() + if message["type"] == "http.request": + body = message.get("body", b"") + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"application/octet-stream")], + } + ) + await send({"type": "http.response.body", "body": body}) + + +def test_remote_config_requires_a_strong_static_bearer() -> None: + with pytest.raises(RemoteConfigurationError, match="required"): + RemoteConfig.from_environ({}) + with pytest.raises(RemoteConfigurationError, match="at least 32 bytes"): + RemoteConfig.from_environ({"RECON_REMOTE_BEARER_TOKEN": "short"}) + with pytest.raises(RemoteConfigurationError, match="without whitespace"): + RemoteConfig.from_environ({"RECON_REMOTE_BEARER_TOKEN": "a" * 31 + " "}) + + +def test_remote_config_reads_a_valid_static_bearer_from_the_process_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("RECON_REMOTE_BEARER_TOKEN", TOKEN) + + config = RemoteConfig.from_environ() + + assert config.auth_mode == "static-bearer" + assert config.bearer_token == TOKEN + assert "bearer_token" not in repr(config) + assert config.port == 8080 + assert config.max_request_bytes == 1024 * 1024 + + +def test_remote_config_supports_explicit_trusted_platform_auth() -> None: + config = RemoteConfig.from_environ( + { + "RECON_REMOTE_AUTH_MODE": "trusted-platform", + "RECON_REMOTE_HOST": "127.0.0.1", + "RECON_REMOTE_PORT": "8000", + "RECON_REMOTE_MAX_REQUEST_BYTES": "2048", + "RECON_REMOTE_ALLOWED_HOSTS": "recon.example,recon.example:443", + "RECON_REMOTE_ALLOWED_ORIGINS": "https://console.example/", + } + ) + + assert config.auth_mode == "trusted-platform" + assert config.bearer_token is None + assert config.bind_host == "127.0.0.1" + assert config.port == 8000 + assert config.max_request_bytes == 2048 + assert config.allowed_hosts == frozenset({"recon.example", "recon.example:443"}) + assert config.allowed_origins == frozenset({"https://console.example"}) + + +@pytest.mark.parametrize( + ("environment", "message"), + [ + ({"RECON_REMOTE_AUTH_MODE": "none"}, "AUTH_MODE"), + ( + {"RECON_REMOTE_AUTH_MODE": "trusted-platform", "RECON_REMOTE_BEARER_TOKEN": TOKEN}, + "must be unset", + ), + ({"RECON_REMOTE_AUTH_MODE": "trusted-platform", "RECON_REMOTE_PORT": "NaN"}, "integer"), + ({"RECON_REMOTE_AUTH_MODE": "trusted-platform", "RECON_REMOTE_HOST": "bad host"}, "HOST"), + ( + {"RECON_REMOTE_AUTH_MODE": "trusted-platform", "RECON_REMOTE_MAX_REQUEST_BYTES": "999"}, + "between 1024", + ), + ({"RECON_REMOTE_AUTH_MODE": "trusted-platform", "RECON_REMOTE_PORT": "0"}, "between 1 and 65535"), + ( + {"RECON_REMOTE_AUTH_MODE": "trusted-platform", "RECON_REMOTE_ALLOWED_HOSTS": "https://bad.example"}, + "exact host values", + ), + ( + {"RECON_REMOTE_AUTH_MODE": "trusted-platform", "RECON_REMOTE_ALLOWED_ORIGINS": "https://example.com/path"}, + "without paths", + ), + ], +) +def test_remote_config_rejects_ambiguous_or_malformed_values( + environment: dict[str, str], + message: str, +) -> None: + with pytest.raises(RemoteConfigurationError, match=message): + RemoteConfig.from_environ(environment) + + +@pytest.mark.parametrize( + "origin", + [ + "ftp://example.com", + "https:///missing-host", + "https://user@example.com", + "https://user:password@example.com", + "https://example.com/path", + "https://example.com?query=yes", + "https://example.com#fragment", + ], +) +def test_remote_config_rejects_non_origin_urls(origin: str) -> None: + with pytest.raises(RemoteConfigurationError, match="without paths"): + RemoteConfig.from_environ( + { + "RECON_REMOTE_AUTH_MODE": "trusted-platform", + "RECON_REMOTE_ALLOWED_ORIGINS": origin, + } + ) + + +def test_remote_config_rejects_non_ascii_bearers_and_whitespace_hosts() -> None: + with pytest.raises(RemoteConfigurationError, match="ASCII bearer"): + RemoteConfig.from_environ({"RECON_REMOTE_BEARER_TOKEN": "a" * 31 + "é"}) + with pytest.raises(RemoteConfigurationError, match="exact host"): + RemoteConfig.from_environ( + { + "RECON_REMOTE_AUTH_MODE": "trusted-platform", + "RECON_REMOTE_ALLOWED_HOSTS": "bad host", + } + ) + + +@pytest.mark.asyncio +async def test_health_is_unauthenticated_but_has_security_headers() -> None: + app = RemoteSecurityMiddleware(_echo_app, _config()) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://recon.example") as client: + response = await client.get("/health") + + assert response.status_code == 200 + assert response.text == "ok\n" + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://recon.example") as client: + head = await client.head("/health") + + assert head.status_code == 200 + assert head.content == b"" + + +@pytest.mark.asyncio +async def test_static_bearer_fails_closed_and_authorized_body_is_replayed() -> None: + app = RemoteSecurityMiddleware(_echo_app, _config()) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://recon.example") as client: + missing = await client.post("/mcp", content=b"request") + wrong = await client.post("/mcp", content=b"request", headers={"Authorization": "Bearer wrong"}) + malformed = await client.post("/mcp", content=b"request", headers={"Authorization": TOKEN}) + accepted = await client.post( + "/mcp", + content=b"request", + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + authorized_get = await client.get("/mcp", headers={"Authorization": f"Bearer {TOKEN}"}) + + assert missing.status_code == 401 + assert missing.headers["www-authenticate"] == "Bearer" + assert wrong.status_code == 401 + assert malformed.status_code == 401 + assert accepted.status_code == 200 + assert accepted.content == b"request" + assert accepted.headers["referrer-policy"] == "no-referrer" + assert authorized_get.status_code == 200 + + +@pytest.mark.asyncio +async def test_host_origin_method_and_size_guards_reject_before_dispatch() -> None: + config = _config( + allowed_hosts=frozenset({"recon.example"}), + allowed_origins=frozenset({"https://console.example"}), + ) + app = RemoteSecurityMiddleware(_echo_app, config) + auth = {"Authorization": f"Bearer {TOKEN}"} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://recon.example") as client: + wrong_host = await client.post("https://other.example/mcp", content=b"x", headers=auth) + wrong_origin = await client.post( + "/mcp", + content=b"x", + headers={**auth, "Origin": "https://other.example"}, + ) + allowed_origin = await client.post( + "/mcp", + content=b"x", + headers={**auth, "Origin": "https://console.example"}, + ) + health_post = await client.post("/health") + oversized = await client.post("/mcp", content=b"x" * 1025, headers=auth) + + assert wrong_host.status_code == 421 + assert wrong_origin.status_code == 403 + assert allowed_origin.status_code == 200 + assert health_post.status_code == 405 + assert health_post.headers["allow"] == "GET, HEAD" + assert oversized.status_code == 413 + + +@pytest.mark.asyncio +async def test_trusted_platform_mode_relies_on_the_outer_ingress() -> None: + app = RemoteSecurityMiddleware( + _echo_app, + _config(auth_mode="trusted-platform", bearer_token=None), + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://recon.example") as client: + response = await client.post("/mcp", content=b"platform-authenticated") + + assert response.status_code == 200 + assert response.content == b"platform-authenticated" + + +def _scope(*, headers: list[tuple[bytes, bytes]], method: str = "POST") -> Scope: + return cast( + Scope, + { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "https", + "path": "/mcp", + "raw_path": b"/mcp", + "query_string": b"", + "root_path": "", + "headers": headers, + "client": ("127.0.0.1", 1234), + "server": ("recon.example", 443), + }, + ) + + +async def _invoke( + app: RemoteSecurityMiddleware, + scope: Scope, + incoming: list[dict[str, Any]], +) -> list[dict[str, Any]]: + messages = list(incoming) + sent: list[dict[str, Any]] = [] + + async def receive() -> Any: + return messages.pop(0) + + async def send(message: Any) -> None: + sent.append(message) + + await app(scope, receive, send) + return sent + + +@pytest.mark.asyncio +async def test_duplicate_security_headers_and_invalid_content_lengths_fail_closed() -> None: + app = RemoteSecurityMiddleware(_echo_app, _config()) + auth = (b"authorization", f"Bearer {TOKEN}".encode()) + host = (b"host", b"recon.example") + cases = [ + ([(b"host", b""), auth], 400), + ([host, (b"origin", b"https://one.example"), (b"origin", b"https://two.example"), auth], 403), + ([host, auth, auth], 401), + ([host, auth, (b"content-length", b"1"), (b"content-length", b"1")], 400), + ([host, auth, (b"content-length", b"NaN")], 400), + ([host, auth, (b"content-length", b"-1")], 400), + ] + + for headers, expected_status in cases: + sent = await _invoke( + app, + _scope(headers=headers), + [{"type": "http.request", "body": b"x", "more_body": False}], + ) + assert sent[0]["status"] == expected_status + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("incoming", "expected_status"), + [ + ([{"type": "http.disconnect"}], 400), + ([{"type": "http.request", "body": "not-bytes", "more_body": False}], 400), + ( + [ + {"type": "extension.message"}, + {"type": "http.request", "body": b"x" * 700, "more_body": True}, + {"type": "http.request", "body": b"x" * 400, "more_body": False}, + ], + 413, + ), + ], +) +async def test_streamed_body_errors_fail_closed( + incoming: list[dict[str, Any]], + expected_status: int, +) -> None: + app = RemoteSecurityMiddleware(_echo_app, _config()) + sent = await _invoke( + app, + _scope( + headers=[ + (b"host", b"recon.example"), + (b"authorization", f"Bearer {TOKEN}".encode()), + ] + ), + incoming, + ) + + assert sent[0]["status"] == expected_status + + +@pytest.mark.asyncio +async def test_replayed_request_disconnects_after_its_single_body() -> None: + receive = remote_server._replay_receive(b"request") + + first = await receive() + second = await receive() + + assert first == {"type": "http.request", "body": b"request", "more_body": False} + assert second == {"type": "http.disconnect"} + + +@pytest.mark.asyncio +async def test_non_http_scopes_preserve_lifespan_but_reject_websockets() -> None: + dispatched: list[str] = [] + + async def recording_app(scope: Scope, receive: Receive, send: Send) -> None: + dispatched.append(scope["type"]) + + async def receive() -> Any: + return {"type": "lifespan.shutdown"} + + sent: list[dict[str, Any]] = [] + + async def send(message: Any) -> None: + sent.append(message) + + app = RemoteSecurityMiddleware(recording_app, _config()) + lifespan_scope = cast(Scope, {"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}) + websocket_scope = cast( + Scope, + { + "type": "websocket", + "asgi": {"version": "3.0"}, + "scheme": "wss", + "path": "/mcp", + "raw_path": b"/mcp", + "query_string": b"", + "root_path": "", + "headers": [], + "client": ("127.0.0.1", 1234), + "server": ("recon.example", 443), + "subprotocols": [], + }, + ) + + await app(lifespan_scope, receive, send) + await app(websocket_scope, receive, send) + + assert dispatched == ["lifespan"] + assert sent == [{"type": "websocket.close", "code": 1008, "reason": "unsupported"}] + + +@dataclass +class _Annotations: + readOnlyHint: bool | None + + +@dataclass +class _Tool: + name: str + annotations: _Annotations | None + + +@dataclass +class _Settings: + host: str = "127.0.0.1" + json_response: bool = False + stateless_http: bool = False + transport_security: object | None = object() + + +class _FakeMCP: + def __init__(self) -> None: + self.settings = _Settings() + self.removed: list[str] = [] + self.application = _echo_app + self.tools = [ + _Tool("lookup_tenant", _Annotations(True)), + _Tool("inject_ephemeral_fingerprint", _Annotations(False)), + _Tool("list_ephemeral_fingerprints", _Annotations(True)), + _Tool("unannotated", None), + ] + + async def list_tools(self) -> list[_Tool]: + return self.tools + + def remove_tool(self, name: str) -> None: + self.removed.append(name) + + def streamable_http_app(self) -> Any: + return self.application + + +@pytest.mark.asyncio +async def test_remote_mcp_is_stateless_and_exposes_only_useful_read_only_tools() -> None: + fake = _FakeMCP() + + application = await prepare_remote_mcp(fake) + + assert application is _echo_app + assert fake.removed == [ + "inject_ephemeral_fingerprint", + "list_ephemeral_fingerprints", + "unannotated", + ] + assert fake.settings.host == "0.0.0.0" # noqa: S104 + assert fake.settings.json_response is True + assert fake.settings.stateless_http is True + assert fake.settings.transport_security is None + + +def test_build_remote_application_wraps_the_filtered_mcp() -> None: + application = build_remote_application(_config(), _FakeMCP()) + + assert isinstance(application, RemoteSecurityMiddleware) + + +def test_build_remote_application_rejects_an_unadopted_sdk_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(remote_server, "SDK_FAMILY", "v2") + + with pytest.raises(RemoteConfigurationError, match="supported MCP v1"): + build_remote_application(_config(), _FakeMCP()) diff --git a/tests/test_strategic_gap_audit.py b/tests/test_strategic_gap_audit.py index eb71aaa7..d2cc65e5 100644 --- a/tests/test_strategic_gap_audit.py +++ b/tests/test_strategic_gap_audit.py @@ -27,7 +27,7 @@ def test_strategic_gap_audit_prioritizes_product_quality_without_runtime_expansi for required in ( "The highest-value work is not runtime expansion", "evidence semantics, measured utility, catalog quality", - "completed MCP candidate matrix", + "completed stable MCP matrix", "aggregate-safe product-quality baseline", "does not add CLI, MCP, JSON, fingerprint, schema, dependency, or network behavior", "Runtime expansion, broad catalog growth, stable-surface promotion, " @@ -96,7 +96,7 @@ def test_strategic_gap_audit_preserves_private_data_and_release_boundaries() -> def test_strategic_gap_audit_cites_current_external_standards() -> None: text = _read(AUDIT) - assert "Checked: 2026-07-17." in text + assert "Checked: 2026-07-28." in text for url in ( "https://www.acm.org/publications/policies/artifact-review-and-badging-current", @@ -109,7 +109,8 @@ def test_strategic_gap_audit_cites_current_external_standards() -> None: "https://help.zenodo.org/docs/github/describe-software/citation-file/", "https://help.zenodo.org/docs/github/describe-software/zenodo-json/", "https://arxiv.org/abs/2605.06508", - "https://modelcontextprotocol.io/development/roadmap", + "https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/", + "https://modelcontextprotocol.io/docs/getting-started/intro", "https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/about-issue-and-pull-request-templates", ): assert url in text From cfed51a5fbf98c2dee8963b58d8ea043de06f43b Mon Sep 17 00:00:00 2001 From: Nick Seal <32712898+blisspixel@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:04:20 -0700 Subject: [PATCH 2/2] Add cross-platform Terraform provider checksums --- deploy/gcp-cloud-run/.terraform.lock.hcl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/gcp-cloud-run/.terraform.lock.hcl b/deploy/gcp-cloud-run/.terraform.lock.hcl index f64e652c..40e38f08 100644 --- a/deploy/gcp-cloud-run/.terraform.lock.hcl +++ b/deploy/gcp-cloud-run/.terraform.lock.hcl @@ -5,6 +5,9 @@ provider "registry.terraform.io/hashicorp/google" { version = "7.42.0" constraints = ">= 7.0.0, < 8.0.0" hashes = [ + "h1:6qNk18qjViinYxnjAEix5O+qHPMmXXHdzCU1IpEJLqg=", + "h1:JqhNUoY3Jw6g4lfznOd4B8qXh2lNetk5/W+dWVZJUwI=", + "h1:OgWsoxTL8UjiDXmmqrK3twhvUEFI0IfIr3NzCmUeAGk=", "h1:gB0UkvO/UrEXplvJ/7o0YwGTp54NDkEO8jkPzgVrOW8=", "zh:30b25728203b9208a167fac3f9880c10242fc5accdd29ba01b21355566fc4e3d", "zh:4468f6ea772e991d890724e44f628a24dae44c9028af654469454d05b00b10ec",