Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "hacker-cb/1c-odata" }],
"commit": false,
"fixed": [["@1c-odata/client", "@1c-odata/metadata", "@1c-odata/cli", "@1c-odata/mcp"]],
"fixed": [["@1c-odata/client", "@1c-odata/metadata", "@1c-odata/cli", "@1c-odata/mcp", "@1c-odata/mcp-server"]],
"linked": [],
"access": "public",
"baseBranch": "master",
Expand Down
2 changes: 1 addition & 1 deletion .changeset/mcp-connection-source-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"@1c-odata/mcp": minor
---

Refactor `@1c-odata/mcp` for multi-tenant reuse (prep for a remote multi-tenant host).
Refactor `@1c-odata/mcp` for multi-tenant reuse (prep for `@1c-odata/mcp-server`).

- New `@1c-odata/mcp/internal` subpath exposing the reusable building blocks β€” `ConnectionPool`, the new `ConnectionSource`/`FileConnectionSource` seam, `ReadPool`, the read-only tool registrators (`registerSchemaTools`/`registerDataTools`/`registerServerInfoTool`), and the response-limit helpers. The connection-management tools stay off this surface (admin-only).
- `ConnectionPool` now takes a `ConnectionSource` (where connections and secrets come from) instead of `{ dataDir }`; the local stdio server injects a `FileConnectionSource` (config.json + keychain) and is unchanged. The read-only tool registrators now accept a `ReadPool`.
Expand Down
2 changes: 1 addition & 1 deletion .changeset/mcp-db-secret-source-and-verify-connectivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"@1c-odata/mcp": patch
---

Prep `@1c-odata/mcp/internal` for a DB-backed multi-tenant host. Additive only β€” no CLI, MCP-tool, on-disk, or local stdio behavior changed.
Prep `@1c-odata/mcp/internal` for a DB-backed multi-tenant host (`@1c-odata/mcp-server`). Additive only β€” no CLI, MCP-tool, on-disk, or local stdio behavior changed.

- `SecretSource` gains a `'db'` variant so a DB-backed `ConnectionSource` can report the real password origin in `list_connections`. `SecretStore` itself never returns it (file/keychain/env only).
- `@1c-odata/mcp/internal` now re-exports `verifyConnectivity` β€” the standalone `$metadata` reachability probe (no `dataDir` dependency), reused by a remote host to verify a base before saving it. The connection-management functions (`upsertConnection`/`removeConnection`/`updateConnectionCredentials`/`setConnectionLabel`) stay private β€” they are bound to the file-backed config.
12 changes: 0 additions & 12 deletions .changeset/mcp-internal-reexports.md

This file was deleted.

33 changes: 33 additions & 0 deletions .changeset/mcp-server-admin-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@1c-odata/mcp-server": minor
"@1c-odata/mcp": patch
---

feat(mcp-server): admin panel β€” role-gated server-rendered console (Slice 4)

Add an internal, `admin`-role-gated admin console to the multi-tenant HTTP
server (mounted at `/admin`, only on the DB-tenancy path). Server-rendered with
Express + Eta + a vendored, CSP-safe htmx (no CDN; `script-src 'self'`).

- **Dashboard** with a DB-aware `server_info` and a health table that polls the
`health` table every 10 s.
- **Base CRUD** with verify-before-save: every create/edit runs
`verifyConnectivity` first and persists nothing on failure; the 1Π‘ password is
sealed write-only (AES-256-GCM via the keyring) and the process-global
connection pool is `refresh()`-ed after each edit.
- **Grant editor** β€” user Γ— base matrix backed by `GrantRepo` (adds
`listByBase`); toggling a cell grants/revokes immediately.
- **User management** via the better-auth admin API (`createUser` / `setRole`).
- **Health job** β€” a single-instance `setInterval` that periodically probes each
base with `verifyReachability` (a light GET on the OData service root, not a full
`$metadata` download) and records `ok`/`auth_failed`/`unreachable`;
started/stopped with the server lifecycle.
- **`admin-create` CLI subcommand** β€” header-less first-admin bootstrap seed
(better-auth ships no CLI for this).

The gate reads the better-auth browser session (`getSession` + `admin` role),
distinct from the Bearer/JWT machine path on `/mcp`.

`@1c-odata/mcp` (patch): re-export `assertValidConnectionName` /
`isValidConnectionName` from `/internal` so the admin write path enforces the
same ASCII connection-name rule as the file-backed store.
24 changes: 24 additions & 0 deletions .changeset/mcp-server-auth-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@1c-odata/mcp-server": minor
---

feat(mcp-server): OAuth 2.1 authorization on the HTTP MCP server

The Streamable HTTP MCP server can now require a Bearer JWT on `/mcp`. Enable it
by passing `--public-url <https-origin>` (or `ONEC_MCP_PUBLIC_URL`) to `serve`;
`BETTER_AUTH_SECRET` is then required.

- Embeds a better-auth authorization server (`jwt()` + `admin()` +
`@better-auth/oauth-provider`) mounted at `/api/auth`, backed by a Postgres
store β€” embedded PGlite for dev/tests, `pg` (via `--pg-url`/`DATABASE_URL`) for
prod. better-auth's own tables only; no per-user base scoping yet.
- Dynamic Client Registration, `/sign-in` and `/consent` pages, RFC 8414 AS
metadata and RFC 9728 Protected Resource Metadata
(`/.well-known/oauth-protected-resource/mcp`) served CORS-open at the root.
- `/mcp` is gated by MCP's `requireBearerAuth`; access tokens are asymmetric JWTs
whose `aud` is the MCP resource id (`${publicUrl}/mcp`), verified offline with
`jose` against the AS's JWKS (issuer + audience pinned, JWT-only).

Without `--public-url` the server behaves exactly as before (no auth).

Internal: `createHttpServer` is now async and returns `{ server, close }`.
12 changes: 12 additions & 0 deletions .changeset/mcp-server-deploy-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@1c-odata/mcp-server": patch
---

fix(mcp-server): deploy-stack hardening surfaced by CI smoke coverage

- `compose.yml`: give the `mcp` service a `/healthz` healthcheck and make `caddy`
wait for `service_healthy`, so first requests don't 502 during boot/migrations
and a boot crash-loop is visible in `docker compose ps`.
- Close idle keep-alive sockets on SIGTERM (`server.closeIdleConnections()`) so
shutdown completes within an orchestrator's grace window; in-flight requests
still drain normally.
10 changes: 10 additions & 0 deletions .changeset/mcp-server-ghcr-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@1c-odata/mcp-server": minor
---

Publish an official multi-arch (`amd64` + `arm64`) container image to GHCR at
`ghcr.io/hacker-cb/1c-odata-mcp-server` on every release, tagged with the exact
version (`X.Y.Z`), the minor (`X.Y`), and `latest`. Run the Compose stack straight
from the image via the new `deploy/compose.prod.yml` overlay β€” no repo checkout or
build toolchain required. The image carries SLSA build provenance, matching the npm
packages.
25 changes: 25 additions & 0 deletions .changeset/mcp-server-http-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@1c-odata/mcp-server": minor
---

Add `@1c-odata/mcp-server`: a stateful Streamable HTTP MCP server that exposes the
read-only 1Π‘:Enterprise OData V3 tools (`query`, `get_entity`, `count`,
`list_entities`, `describe_entity`, `list_enums`, `list_connections`,
`refresh_metadata`, `register_query`, `server_info`) over HTTP at `/mcp`, for
Claude custom connectors. Read-only surface β€” the connection-management tools are
intentionally excluded.

Three run modes, each opting into the next:

- **no-auth** β€” loopback only, over a `FileConnectionSource` from `--data-dir`.
- **OAuth 2.1** (`--public-url`) β€” an embedded better-auth authorization server with
Dynamic Client Registration + PKCE; tokens are verified locally against JWKS.
- **multi-tenancy** (`--enc-key`) β€” bases live in Postgres with their 1Π‘ passwords
encrypted at rest (AES-256-GCM, bound to the base name, key rotatable), per-user
grants, and a server-rendered `/admin` panel whose first admin is bootstrapped
through a one-time `/setup` token.

Programmatic entry point: `createHttpServer()` is **async** and resolves to a handle
β€” `{ server, close }` β€” where `server` is an unstarted `http.Server` you `listen()`
yourself and `close()` releases the auth store. Turnkey self-host (server + Postgres
+ Caddy auto-HTTPS) ships in `packages/mcp-server/deploy`.
19 changes: 19 additions & 0 deletions .changeset/mcp-server-in-process-jwks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@1c-odata/mcp-server': patch
---

Fix JWT verification behind a reverse proxy without hairpin-NAT.

The resource server used to verify bearer tokens by fetching its own **public**
origin β€” first `${issuer}/.well-known/oauth-authorization-server`, then the
`jwks_uri` it advertises. In a single-host deploy the container often cannot
resolve or reach that origin (no hairpin-NAT / split-horizon DNS), so every
bearer check failed and OAuth mode was effectively dead.

The authorization server runs in the same process, so its signing keys are now
read in-process from better-auth instead of over the network. Public discovery is
unchanged β€” `/.well-known/*` still advertises the public `jwks_uri` that external
clients need. This also removes the URL-driven fetch, and with it the SSRF surface
that the `jwks_uri` origin-pin existed to contain.

No configuration change is required.
21 changes: 21 additions & 0 deletions .changeset/mcp-server-jwks-read-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@1c-odata/mcp-server': patch
---

Bound the JWKS read on the bearer-auth path.

Token verification shares a single in-flight read of the authorization server's
signing keys across every concurrent request. That read had no deadline, so a read
that *hung* β€” rather than failed β€” held every bearer check for the life of the
process, and none of the retry paths could run, because they all sit downstream of
that promise settling. A hang is reachable in a Postgres deploy: the connection
pool has no checkout deadline by default, so a saturated pool waits indefinitely,
and a lock can stall the query after checkout.

Each read now has a 5s deadline. Exceeding it frees the waiting requests and clears
the shared promise, so the next request retries; on the max-age refresh path the
timeout is absorbed and the last good key set keeps serving.

The deadline is per read, and one request can make two β€” an aged set whose refresh
times out, then a miss on a rotated-in key β€” so a single bearer check is bounded at
10s in that case rather than 5s.
37 changes: 37 additions & 0 deletions .changeset/mcp-server-multi-tenancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@1c-odata/mcp-server': minor
'@1c-odata/mcp': patch
---

feat(mcp-server): multi-tenant, per-user base scoping with encrypted secrets

Adds a database-backed multi-tenancy layer to the remote MCP server, active
**only when auth is enabled** (a `--public-url` deployment that also supplies an
encryption key). Without auth, the server is unchanged: the file-backed
`FileConnectionSource` and an unscoped connection pool.

- **Encrypted secrets at rest.** 1Π‘ passwords are sealed with AES-256-GCM
(`src/store/crypto.ts`). The base name is the AAD, so a stored secret is
cryptographically bound to its base β€” a swapped ciphertext fails to decrypt. Each
row records the `key_id` that sealed it, so the KEK can be rotated: supply the
current key via `--enc-key` or `ONEC_MCP_ENC_KEY` (base64 32-byte;
`openssl rand -base64 32`) and any retired keys via `ONEC_MCP_ENC_KEYS_PREVIOUS`.
A missing/malformed key fails boot loudly.
- **Our tables** (`bases`, `base_secrets`, `grants`, `health`) live in a
hand-written `src/store/tenancy-schema.ts`, merged with better-auth's generated
schema. `grants.sub` FKs `user.id`. The committed `drizzle/0001_*.sql` ships the
DDL and is applied by the drizzle-orm migrator on BOTH paths β€” pglite (dev/tests)
and Postgres (prod) run the exact same SQL.
- **Per-user scoping.** A `DbConnectionSource` decrypts secrets at read time; a
per-session `ScopedPool` fronts the shared pool and restricts every operation
to the caller's granted bases, resolving grants **fresh on every tool-call** so
a revoked grant takes effect on the user's next call β€” no reconnect. An
ungranted base yields the **same** `No connection named "…"` error as a base
that does not exist, so scoping never leaks base existence.
- **Session ↔ subject binding.** Each MCP session is pinned to the `sub` that
opened it; a different valid token replaying that session id is rejected with
`403` β€” a valid bearer token can no longer hijack another user's session.

`@1c-odata/mcp` gets a one-line internal re-export (`InvalidArgumentError` from
`/internal`) so the scoped pool throws the pool's canonical not-found error
without a new dependency.
6 changes: 4 additions & 2 deletions .changeset/node-24-lts-floor.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
'@1c-odata/metadata': minor
'@1c-odata/cli': minor
'@1c-odata/mcp': minor
'@1c-odata/mcp-server': minor
---

**Breaking:** the minimum supported Node version is now 24.18.0 (was 22.21.0).
Expand All @@ -12,8 +13,9 @@ the active LTS line and is supported until 2028-04-30. Installing on Node 22
will now be refused or warned about by npm/pnpm, depending on your client.

No source change accompanies this. The library does not yet use any API that
Node 22 lacks β€” the floor moves so that the version CI exercises and the version
the packages advertise are one and the same. Keeping the advertised floor below the tested one meant the
Node 22 lacks β€” the floor moves so that the version CI exercises, the version
the published container image runs, and the version the packages advertise are
one and the same. Keeping the advertised floor below the tested one meant the
promise was never actually verified, which is the defect this closes.

`@types/node` is pinned to the matching major for the same reason: types
Expand Down
25 changes: 25 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Keep the mcp-server Docker build context lean + hermetic β€” dependencies and
# every package's dist/ are reinstalled/rebuilt inside the image, not copied.

**/node_modules
**/dist
**/.turbo
**/coverage
**/*.tsbuildinfo
**/*.log

# VCS / CI / editor / agent noise
.git
.github
.claude
.vscode
.DS_Store

# Large trees not needed to build the server (kept out of context)
docs
snapshots

# Never bake local env/secrets into image layers (but keep the templates)
**/.env
**/.env.*
!**/.env.example
Loading
Loading