Skip to content

Converge hosted-MCP OAuth + parity tools + app SSO (v4.20.0) - #124

Merged
kennethphough merged 34 commits into
masterfrom
integration/mcp-oauth-sso
Aug 3, 2026
Merged

Converge hosted-MCP OAuth + parity tools + app SSO (v4.20.0)#124
kennethphough merged 34 commits into
masterfrom
integration/mcp-oauth-sso

Conversation

@kennethphough

Copy link
Copy Markdown
Member

Converges five validated-but-unmerged feature branches into the release line so a single install serves the full hosted-MCP surface: the OAuth connector (Claude.ai / ChatGPT web), the app/media/controller MCP tools, and app-level Microsoft/OIDC SSO.

Each branch was cut from the same base (current master), 0 behind, touching near-disjoint files. The only merge conflict was an additive routing block in src/Core/Api.php (both /oauth/* and /sso/* dispatch), resolved to keep both.

What's included

  • #551 — OAuth 2.1 authorization server (OAuthEndpoint, KyteOAuthClient/KyteOAuthCode, RFC 8414/9728 discovery, /oauth/* routing, /mcp WWW-Authenticate). Lets Claude.ai/ChatGPT add /mcp as a hosted connector; mints scoped kmcp_live_ tokens via auth-code + PKCE. Migration 4.17.0.
  • #343 — controller MCP tools (ControllerTools: create/update/delete_controller, schema scope).
  • #345 — media MCP tools (MediaTools: list/read/create/delete_media, base64 S3 upload).
  • app tools + provisioning identity (AppTools create/update/delete_application; DBI::getProvisioningConnection + explicit privilege grant; async app teardown via Application.status + SiteProvisioningWorker; logs-bucket removal). Migration 4.18.0.
  • #560 — app-level Microsoft/OIDC SSO (SsoEndpoint, KyteAppIdentityProvider/KyteSsoState/KyteSsoCode/KyteSsoIdentity, JwtEndpoint::issueSession). Kyte as OIDC relying party. Migration 4.19.0.

Security review

  • OAuth (#551): reviewed on its own branch — no HIGH/MED; CORS hardening applied.
  • SSO (#560): full /security-review this cycle. Findings closed + adversarially re-verified: identity now bound to the immutable id_token sub (not mutable email; guest-exclusion + no-rebind), login-CSRF closed via browser-bound state cookie, and SSO refuses the platform KyteUser fallback. Rejected findings: Host-header redirect_uri (dead code), cross-account KyteUser (blocked by existing preAuth aud guard).

Validation (dev19, integration branch deployed)

OAuth discovery returns proper metadata; /mcp carries the discovery header; consent page + endpoint healthy; SSO /authorize 302 intact; a real Microsoft login completed end-to-end (subject-linked session minted); Claude.ai connector requests + receives full scope (read,draft,commit,provision,schema).

Notes

  • Migrations 4.174.19 are idempotent; a converged tag of v4.20.0 is intended after merge.
  • Excludes feature/325-* (schema/model-layer — separate in-progress build).
  • php -l clean across changed files; full PHPStan+baseline intended to run here in CI (only php -l was runnable on dev19).

🤖 Generated with Claude Code

kennethphough and others added 30 commits July 31, 2026 22:10
…nticate

P1-A of the hosted MCP OAuth connector (KYTE-#551). Foundation slice — makes
the /mcp endpoint OAuth-discoverable by Claude.ai / ChatGPT web connectors.
No behavior change to existing token auth.

- KyteOAuthClient + KyteOAuthCode models (src/Mvc/Model) + migration
  4.17.0_oauth_as.sql (idempotent CREATE TABLE, mirrors 4.6.0 conventions).
- OAuthEndpoint (src/Core/Auth): serves RFC 8414 authorization-server metadata
  + RFC 9728 protected-resource metadata; register/authorize/token return 501
  until P1-B/C/D (#553/#554/#555). Pure process() like JwtEndpoint.
- Api::route(): dispatch /oauth/* + /.well-known/oauth-* to OAuthEndpoint
  before the MVC pipeline (same pattern as /mcp, /jwt).
- Mcp\Endpoint: /mcp 401 now carries WWW-Authenticate: Bearer
  resource_metadata="…/.well-known/oauth-protected-resource" (RFC 9728) so
  connectors auto-discover the AS.

Decision: hand-rolled auth-code+PKCE flow (not league/oauth2-server) — Kyte's
access token is the opaque kmcp_live_ (no JWT); only crypto is PKCE S256 +
random_bytes, already Kyte's pattern. See docs/design/hosted-mcp-oauth.md.

PHPStan clean; php -l clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /oauth/register: Claude/ChatGPT self-register as public PKCE clients
(token_endpoint_auth_method=none) before the authorization-code flow.

- Validates redirect_uris (required; https, or http on loopback per RFC 8252;
  count + length capped), grant_types (authorization_code only), response_types
  (code only); filters scope to the AS's kmcp scopes (default read).
- Persists a KyteOAuthClient (kyte_account=0, unscoped until consent) with an
  opaque CSPRNG client_id (kyoc_…); returns the RFC 7591 §3.2.1 client info.

Open registration is the MCP model — the gate is consent (P1-C) + PKCE (P1-D),
not client auth. Rate-limiting/hardening tracked in #556. PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend half of the consent flow (KYTE-#551 P1-C). The interactive page
lives in Shipyard (next); this adds the endpoints it calls.

- authorization_endpoint (AS metadata) now points at SHIPYARD_URL/oauth/authorize
  (consent reuses Shipyard's login). GET /oauth/authorize on the API 302-redirects
  there too, preserving the OAuth query params.
- GET /oauth/consent/client (authed): validates the authorize request (client +
  exact redirect_uri match + response_type=code + PKCE S256) and returns client
  display info (name, requested scopes) for the "Authorize Claude to access Kyte"
  screen.
- POST /oauth/consent/approve (authed): mints a single-use, 300s KyteOAuthCode
  bound to the consenting user's account + PKCE challenge (account-wide, v1);
  returns {redirect_uri, code, state} for the page to redirect back to the client.
- User auth via AuthDispatcher (JwtSessionStrategy/HMAC) → $api->user/account; an
  MCP bearer is rejected (consent needs a user session).

PHPStan clean. Authed happy-path validates with the Shipyard page + login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the backend OAuth flow (KYTE-#551 P1-D). POST /oauth/token,
authorization_code grant:

- Parses form-urlencoded (OAuth standard) or JSON body.
- Looks up the KyteOAuthCode by sha256(code); rejects unknown/consumed/expired
  codes and client_id/redirect_uri mismatches (invalid_grant).
- PKCE S256: base64url(sha256(code_verifier)) must equal the stored challenge
  (hash_equals).
- Single-use: burns the code (consumed_at) before issuing.
- Mints a scoped, account-wide kmcp_live_ KyteMCPToken as the access token
  (same format/storage as a Shipyard-issued token → McpTokenStrategy validates
  it identically), created_by = the consenting user. TTL via KYTE_OAUTH_ACCESS_TTL
  (default 30d). Returns {access_token, token_type, expires_in, scope}.

Backend flow now complete: discover → register → authorize/consent → token.
Remaining: the Shipyard consent page + full browser e2e. PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security review note #1: emitCorsHeaders reflected any Origin AND sent
Access-Control-Allow-Credentials: true on all /oauth/* responses (incl.
consent/approve, which returns the raw auth code). Not exploitable — auth is
header-based (Bearer / X-Kyte-*), never a cookie, so attacker JS can't obtain
the credential — but an auth-code endpoint must not pair credentialed CORS
with a reflected origin. Drop Allow-Credentials; the consent page's fetch
doesn't use credentials, so nothing breaks. PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Shipyard consent page ships as a root .html (like login/password/reset)
so it's in the deploy bundle and served without directory-index concerns.
Update shipyardConsentUrl() to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the "MCP can't create controllers" gap (KYTE-#343). Adds to ControllerTools:
- create_controller(application_id, name, data_model_id?, description?) — new
  custom controller with generated base code; optional data-model binding.
- update_controller(controller_id, name?, description?, data_model_id?) —
  rename/rebind (regenerates base code) / edit description.
- delete_controller(controller_id) — removes the controller + its functions.

All gated by the `schema` scope (structural app changes, same as the model
tools); controller behaviour/code stays draft/commit via write_function_code.
Each goes through ControllerController in INTERNAL mode (like SiteTools), with
every id re-scoped to the token's account first. ControllerController::new has
no $this->user dependency, so it's MCP-clean. PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tool parity for site media libraries (KYTE-#345). New MediaTools:
- list_media(site_id) [read] — metadata of a site's media files.
- read_media(media_id) [read] — metadata + short-lived presigned download URL.
- create_media(site_id, filename, content_base64, content_type?) [provision] —
  server-side upload of base64 bytes (max 5MB) to the site's S3 media bucket
  (the human Shipyard path uses a presigned browser POST, which doesn't fit an
  AI client; the server writes directly via Kyte\Aws\S3::write). Rolls the row
  back if the S3 write fails.
- delete_media(media_id) [provision] — removes the S3 object + the record.

Site-scoped (site → region + owning app AWS creds → media bucket); every id is
re-scoped to the token's account. PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n fresh objects)

A freshly-created Media object only populates the columns passed to create(),
so $m->thumbnail was undefined → PHP warning into the response stream. Use
isset() for the optional fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tool parity: create/update/delete application via MCP. Plus two ApplicationController
cleanups discussed with Kenneth.

ApplicationController::hook_preprocess('new'):
- Remove the logs-bucket creation entirely — the s3LogBucketName/Region fields
  were written at create and read NOWHERE (kyte-php or Shipyard); dead since an
  unfinished intention, and its S3 call had a null-creds bug (fell back to the
  instance role). App creation now needs no S3 credentials.
- Fix $this->user -> $this->account (MCP tokens have no user); created_by falls
  back to null.
- Single forward-looking AWS-credential resolution point (KYTE-#205): inline key
  (Shipyard) OR the account's existing key (MCP, no secrets passed). db_password
  is generated when absent.

AppTools (src/Mcp/Tools): create_application(name, language?), update_application,
delete_application — all `provision` scope, account-scoped, via ApplicationController
internal mode. delete_application refuses to delete an app with live sites (their
AWS teardown is async — avoids orphaning S3/CloudFront/ACM); full cascade teardown
+ app lifecycle status is a separate follow-up.

PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… database)

Provisioning (CREATE/DROP DATABASE + CREATE/DROP USER) needs DBA-level rights the
scoped runtime `kyte` user deliberately lacks (Aurora→MariaDB hardening). Rather
than re-grant those to the web-facing runtime user (a SQL-injection would then be
able to create/drop any database), add a dedicated provisioning identity used
ONLY by the provisioning code path.

- DBI::getProvisioningConnection() — a separate SSL connection as
  KYTE_DB_PROVISION_USERNAME / KYTE_DB_PROVISION_PASSWORD (same host + CA bundle,
  no default DB). Falls back to the main connection when unset, so nothing
  changes on installs that don't provision.
- DBI::createDatabase() now uses it; new DBI::dropDatabase(name, username?) drops
  the tenant DB + its dedicated user (idempotent) via the same connection.
- ApplicationController delete uses DBI::dropDatabase (also cleans up the db user
  the old raw DROP DATABASE leaked). NOTE: still orphans site AWS infra — full
  cascade teardown is #559.

Security win: a SQLi on normal queries can't escalate to server DDL — those run
on the scoped connection. Forward step toward KYTE-#205 (provisioning identity).
PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng user on RDS)

createDatabase granted 'ALL PRIVILEGES' on the tenant db, but the provisioning
identity can only grant privileges it explicitly holds — on RDS 'GRANT ALL' is
denied (surfaced as a misleading 'Access denied … to database'). Grant the
explicit app-relevant set instead (= ALL minus GRANT OPTION); matches the
provisioning user's own grants (#205). Verified against the dev RDS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deleting an app used to drop the tenant DB + soft-delete rows but ORPHAN all
site AWS infra (S3/CloudFront/ACM). Now it's a proper async cascade:

- Application.status ('active'|'deleting'|'deleted') + migration
  4.18.0_application_status.sql (idempotent, defaults 'active').
- ApplicationController delete hook no longer drops anything synchronously: it
  marks the app + each of its sites 'deleting' and DEFERS the base controller's
  soft-delete ($autodelete=false) so the row survives for the worker.
- SiteProvisioningWorker::finalizeDeletingApplications(): each tick, for every
  app in 'deleting', once ALL its sites are fully 'deleted' (their infra torn
  down by advanceDelete), drop the tenant DB + user (DBI::dropDatabase) and
  soft-delete the app. Runs regardless of whether any sites are in flight.
- delete_application (MCP) now initiates the async teardown and returns
  in-progress + a poll hint; app output includes status.

PHPStan clean. UI for the in-progress state is a Shipyard follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
App-level Microsoft/OIDC SSO, foundation slice (KYTE-#560 P1). Kyte as the OIDC
relying party.

- Models: KyteAppIdentityProvider (per-app OIDC config; client_secret KMS-
  encrypted + protected; Shipyard-managed, never MCP), KyteSsoState (in-flight
  state/nonce/PKCE), KyteSsoCode (single-use session hand-off, no tokens at
  rest) + migration 4.19.0_app_sso.sql.
- SsoEndpoint: GET /sso/authorize fully implemented — resolves the app's
  provider config, runs OIDC discovery, builds state+nonce+PKCE, persists a
  KyteSsoState, 302s to the provider's authorization_endpoint. callback +
  exchange are 501 stubs (next slice: token exchange + id_token validation +
  JIT user mapping + Kyte-session minting).
- Api::route(): dispatch /sso/* to SsoEndpoint before the MVC pipeline.

Design: docs/design/app-microsoft-sso.md. PHPStan clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… + session

Completes the P1 backend (KYTE-#560). /sso/callback + /sso/exchange:

- /sso/callback: single-use-consume the KyteSsoState (state/nonce/PKCE), exchange
  the code at the provider token endpoint (client_secret decrypted server-side),
  validate the id_token (Microsoft JWKS via firebase JWK::parseKeySet — signature
  + exp/nbf, then aud=client_id, nonce, concrete-issuer, and tid tenant scoping),
  map the email claim to the app user (JIT-create unless restrict_to_existing),
  mint a single-use KyteSsoCode (no tokens at rest), redirect to the app's
  return_url?sso_code=…
- /sso/exchange: redeem the sso_code → mint the Kyte JWT session via the shared
  JwtEndpoint::issueSession (+ resolveAuthContext for the app's user_model/DB
  context). Same session shape /jwt/login returns.
- client_secret at rest: libsodium secretbox (SsoEndpoint::encrypt/decryptSecret)
  with an install key (KYTE_SSO_SECRET_KEY, else derived from KYTE_JWT_SECRET) —
  replaces the earlier KMS plan (simpler, no per-account key provisioning; behind
  one helper so KMS can swap in later).
- JwtEndpoint: resolveAuthContext + new issueSession made public for reuse.

SECURITY (for the P1 /security-review before exposure): return_url must be
validated against the app's own sites (open-redirect / code-interception).
PHPStan clean. Needs a real Azure app to validate e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r-key alg)

Microsoft's OIDC JWKS keys don't carry an alg member, which makes
firebase/php-jwt JWK::parseKeySet throw 'JWK must contain an alg parameter'
— surfacing as id_token_invalid on every real Microsoft login. Supply RS256
as the default algorithm (all MS v2.0 id_tokens are RS256; JWT::decode still
enforces the token header alg on verify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the open-redirect / sso_code-interception gap flagged for the P1
security review. backToApp previously 302'd to any ?redirect= host with the
single-use code appended.

- isAllowedReturnUrl(): empty return_url ok (code returned as JSON); otherwise
  requires absolute https (http only on loopback) whose host is one of the app's
  own domains — KyteSite cfDomain/aliasDomain + custom Domain.domainName for the
  app's sites.
- Enforced at /authorize (fail fast, before redirecting to the IdP) and again as
  defense-in-depth before the code-carrying redirect in /callback (falls back to
  returning the code as JSON rather than leaking it off-app).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, browser-bound state, no KyteUser fallback

Security review remediation (findings 1-3) before client exposure.

Finding 1 (HIGH, account takeover) — identity was keyed on the mutable/
unverified email/preferred_username claim with no sub binding.
- New KyteSsoIdentity link table: (application, provider, subject) -> app user.
  Returning users resolve by the immutable id_token ; a token bearing
  another user's email can no longer reach that account.
- Drop the preferred_username fallback entirely.
- First-login email linking to a PRE-EXISTING app user is allowed only when the
  asserting tenant is authoritative (pinned single-tenant config whose tid
  matches). In multi-tenant/'common' any tenant can assert any email, so email
  never matches an existing account there — JIT-create a fresh subject-bound user
  instead (or deny under restrict_to_existing).

Finding 2 (login CSRF) — the OAuth state was not bound to the initiating
browser. /authorize now sets an HttpOnly, Secure, SameSite=Lax correlator cookie
and stores its sha256 on KyteSsoState.browser_hash; /callback requires the cookie
to match before consuming the state.

Finding 3 (privilege scope) — SSO could provision/match into the platform
KyteUser table when an app has no user_model. resolveSsoUser + /exchange now
refuse the KyteUser fallback (sso_requires_user_model); JIT create/lookup is
scoped to the app's kyte_account.

Models: KyteSsoIdentity (new), KyteSsoState.browser_hash. Migration adds the
table + an idempotent ALTER for existing installs; corrects the stale
'KMS-encrypted' comments to libsodium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d, dangling-link rebind

Follow-up to the verify pass on the identity hardening. The subject-binding
closed the broad multi-tenant takeover; these close the narrow residuals it left
in the first-login email-match branch:

- Exclude B2B guests from email-linking: only link a pre-existing app user by
  email when the token is a native MEMBER of the pinned tenant (no `idp` claim /
  `acct` != 1). A guest's email is administered by their home tenant, which the
  resource tenant does not own — so guest email is no longer trusted to match an
  existing account (single-tenant first-login takeover vector).
- Never attach a second SSO identity to an account already bound to another
  subject (userAlreadyLinked guard) — an already-provisioned user can't be
  re-bound/hijacked.
- Dangling link (linked user was deleted): re-provision and REBIND the same link
  row instead of minting a fresh orphan on every login (the unique index would
  otherwise reject the second row and churn orphans).
- Factored JIT create into jitCreateUser() (shared by the fresh + rebind paths).

PHPStan/php -l clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e are implemented)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/Core/Api.php
QA surfaced that MCP could edit existing functions (write_function_code) but
not CREATE one. create_function adds a function to a controller via the
FunctionController (generates the type's stub + initial version), then the
caller adds behaviour with write_function_code and publishes with commit_draft.

- schema scope; account-scoped (controller must belong to the token's account).
- Validates function type against the FunctionController template set; hooks/
  overrides are unique-per-controller (FunctionController enforces), custom
  allows multiple.
- Binds a representative account user to $api->user for the internal call:
  FunctionController's initial-version write attributes created_by to $api->user,
  which MCP tokens don't populate (they set account only) — without it the
  version write dereferences null. Restored in a finally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FunctionController creates a baseline version on function-create with
version_type='initial', but the KyteFunctionVersion.version_type column is an
enum(auto_save|manual_save|publish|mcp_draft|mcp_commit) — 'initial' is not a
member, so the insert fails 'Data truncated for column version_type', silently
breaking initial-version creation for BOTH Shipyard and MCP function creation.
'initial' is still used as the sentinel that forces the first version despite no
diff; only the persisted value changes (-> manual_save). Surfaced by the new
create_function MCP tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_page makes a page on a site as an empty draft (no AWS/S3 at create);
the caller adds HTML/CSS/JS with write_page_part and publishes with commit_draft.
Mirrors create_controller/create_function: internal KytePageController, schema
scope, account-scoped, and binds a representative account user for the
page-data/initial-version writes (MCP tokens set account but not $api->user).

Surfaced by QA: MCP could edit existing pages (write_page_part) but not create
one — blocking the UI phase of an A→Z app build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QA hit this: nothing surfaced the API endpoint, so it had to be hand-typed into
the client. get_app_info returns the API endpoint (what kyte-api-js is init'd
with), the MCP endpoint, account number, and — with an application_id — the app
identifier + a kyte_api_js_init hint + each site with its live URL(s)
(cloudfront/alias/custom domains). Account-level (no app id) returns the
endpoint + app list. read scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rver instructions)

Two QA-driven additions:

Script tools (ScriptTools) — MCP could edit an existing script's content
(write_script_content) but not list/read/create/delete. Adds list_scripts,
read_script (read), create_script, delete_script (schema). create_script uses
KyteScriptController internal + binds an account user (MCP tokens set account,
not $api->user); same 'initial'->'manual_save' version_type enum fix as
functions (KyteScriptVersion.version_type is the same restricted enum).

KyteJS guidance — the AI generating page/script JS didn't know Kyte injects the
API client as the global  (k.get/k.post/k.put/k.delete(model,...)), so it
wrote wrong code. MCP can carry guidance two ways, both added: (1) the server
'instructions' field (surfaced on connect — a lightweight always-on skill) now
explains , get_app_info, and the create->write->commit flow; (2) a
get_kytejs_guide tool (read) returns the full signatures + a worked example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…troller_guide

Two follow-ups from QA:

1. create_application now generates the app's kyte_connect snippet (the
   `var k = new Kyte(endpoint, pubKey, iden, acctNum, appId); k.init();` bootstrap
   injected into every published page). Shipyard builds this normally; an
   MCP-created app had an EMPTY one, leaving the global `k` undefined in published
   pages so all frontend JS failed at runtime. Deterministic from the account's
   API key + app identifier; no-op if already set / no key / no endpoint.

2. get_controller_guide (read) — the backend counterpart to get_kytejs_guide.
   Documents the controller hook + method-override signatures (which params are
   by-reference), the $this context ($this->user/account/response/model), the
   Model/ModelObject query API, error handling, and worked examples, so an AI
   writes function code that runs. Wired into the server instructions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kennethphough and others added 4 commits August 2, 2026 19:44
…lete_page

- read_application (read): single app details by id (account-scoped).
- delete_function (schema): remove one function via FunctionController (cleans
  versions + regenerates the controller code); delete_controller still cascades.
- delete_page (schema): remove a page via KytePageController (page-data/versions/
  assignments; + S3 file removal, sitemap rewrite, CloudFront invalidation for a
  published page). Both bind a representative account user for the internal
  controller (MCP tokens set account, not $api->user).

Closes the audited CRUD-matrix gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oller source

Deep audit of get_kytejs_guide and get_controller_guide against kyte-api-js
(kyte-source.js) and ModelController/FunctionController found several claims that
would make an AI emit broken or cross-tenant-leaking code. Corrected both.

get_kytejs_guide:
- response.data is an array ONLY for default CRUD; a custom controller returns
  whatever it set (object/scalar). Success cb gets the WHOLE response object.
- k.sessionDestroy takes ONE completion callback (runs on success OR failure),
  not (onSuccess, onError) — redirect goes there.
- onError may receive a string OR an object OR not fire at all; 403 auto-runs
  session-destroy + redirect.
- formData is a pre-serialized URL-encoded STRING, not a browser FormData.
- headers is a required positional [] before the callbacks. k is pre-init()'ed.

get_controller_guide:
- hook_prequery fires for get + update ONLY (not new/delete).
- hook_response_data's 3rd arg for delete is the $autodelete BOOLEAN (fires
  BEFORE delete; set false to veto) — not a response row.
- overriding a CRUD method REPLACES the base, dropping automatic kyte_account
  scoping/auth/FK — must call parent:: or re-implement (tenant-leak warning).
- custom functions are NOT API-routed; only POST/PUT/GET/DELETE dispatch.
- ModelObject::retrieve 3rd arg is $conditions (NOT $isLike like Model::retrieve);
  Model::retrieve has a 7th $limit; ModelObject::delete is a soft delete (purge()
  hard-deletes); $this->user is an empty object not null (guard isset->id);
  $this->model is the definition array; response['data'] default is a list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rift notes

- ModelController::update() passed $order to hook_prequery uninitialized (an
  undefined-variable-by-ref, unlike get() which sets $order=null). Initialise it.
- Anti-drift: the get_kytejs_guide / get_controller_guide tools are the MCP
  "knowledge base" for AI code generation. Added KEEP-IN-SYNC notes on both guide
  methods AND pointers at the sources of truth (FunctionController::FUNCTION_TYPES,
  ModelController hook declarations) so a material change to the SDK or the
  hook/query contract updates the guide in the same change and doesn't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e flags + configure_app_login

QA (A→Z app build) hit a wall: an AI-built User model couldn't do login because
two server-side settings weren't reachable from the MCP.

- add_attribute / update_attribute now accept password / protected / sensitive
  flags (DataModelController maps password->auto-hash, protected->blank in API
  output, sensitive->log redaction). Descriptions tell the AI to store the
  PLAINTEXT password in signup and let Kyte hash it (avoid double-hash), and to
  set protected so the hash never leaves the server. Surfaced in the summary.
- configure_app_login (provision): sets Application.user_model / username_colname
  / password_colname — the actual reason app login rejected valid credentials
  (SessionController falls back to the platform user table when these are unset).
  Validates the named user model exists in the app.
- read_application now surfaces the login config (user_model/username/password).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kennethphough
kennethphough merged commit 5fb62e8 into master Aug 3, 2026
4 checks passed
@kennethphough
kennethphough deleted the integration/mcp-oauth-sso branch August 3, 2026 04:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant