Skip to content

MCP: emit inputSchema.properties as an object, not [] (breaks strict clients like the Claude CLI)#2017

Open
MWest2020 wants to merge 1500 commits into
developmentfrom
fix/mcp-empty-properties-object
Open

MCP: emit inputSchema.properties as an object, not [] (breaks strict clients like the Claude CLI)#2017
MWest2020 wants to merge 1500 commits into
developmentfrom
fix/mcp-empty-properties-object

Conversation

@MWest2020

Copy link
Copy Markdown
Member

Heropend na de migratie van Codeberg naar GitHub (2026-07-14).
Origineel, met review-historie: https://codeberg.org/Conduction/openregister/pulls/456

rubenvdlinde and others added 30 commits July 10, 2026 16:59
Pre-existing t() source strings (source/EditSource, templates, LLM settings)
were used in Vue but absent from l10n/en.json, failing the l10n-check gate.
Extracted via `check-l10n.js --write` (key === English source). Reduces
carried l10n drift on development; the separate 36-locale parity backlog for
these keys remains pre-existing and out of scope for this PR.
- PHPCS: fix param-doc/inline-if/inline-comment/function-spacing across the
  federation controller, provider, listener, flow service and Application boot
  wiring (phpcbf + manual); add @SPEC exclude tags on the OCM listener.
- PHPStan: match ICloudFederationProvider::notificationReceived signature
  (untyped params, array<array-key,string> return); drop a redundant ?? on a
  non-nullable getShareSecret(); baseline the two QBMapper generic-variance
  entries for FederatedShareMapper insert()/update() (same pattern already
  baselined for SourceMapper).

PHPCS 0 errors, PHPStan OK, unit 17/17 green the CI way.
FunctionSpacing: collapse double blank lines between members to one across the
federation entity, mapper, share service and object-source provider, matching
the standard applied to the rest of the feature.
The DBAL-virtual-registers frontend strings (source/EditSource, templates,
LLM settings) were present in used code + en.json but missing from every
locale, failing the l10n-parity hard gate. Backfill each required locale's
.json with the English source value (non-empty; parity forbids missing/empty).
Clears the carried l10n-parity debt so app-tests.yml goes green. English
placeholders pending real translation via the normal l10n pipeline.
…only `::text` with dialect-aware CAST(_ AS CHAR) (WOO-520)

## Symptoom

`GET /apps/opencatalogi/api/search` crashte op MariaDB backends met:

```
SQLSTATE[42000]: Syntax error near '::text AS _id, _uuid::text AS _uuid,
_slug::text AS _slug, _uri::text AS _uri...' at line 1
```

De crash zit in `MagicMapper::buildUnionSelectPart()` — dat is de per-schema
UNION-arm die opgebouwd wordt zodra een consumer een multi-schema search
uitvoert (`_schemas: [id1, id2]`). WOO-506's SCH-PFTS endpoint doet precies
dat vanaf commit 2387a7b7 (PR #88 op opencatalogi): het roept
`ObjectService::searchObjectsPaginated()` aan met beide `publication` en
`document` schema-ids, waarna deze UNION-builder de crash triggert.

## Root cause

De cast-syntax `col::text` is PostgreSQL-only. MariaDB/MySQL vereisen de
standaard `CAST(col AS CHAR)`. Op de PROPERTY-column projection (lines
1490-1512) was al platform-aware code aanwezig (via `$isPostgres` flag).
De METADATA-column projection erboven (lines 1436-1458) én de NULL-column
placeholders waren dat NIET — die gebruikten onvoorwaardelijk `::text`.

Hydra CI runt op PostgreSQL → geen enkele gate ving deze in. Elke local
dev-env op MariaDB brak zodra WOO-506's endpoint gehit werd.

## Fix

Zelfde platform-switch pattern als bij de property-column loop:

```php
$colExpr = "CAST({$metaCol} AS CHAR) AS {$metaCol}";
if ($isPostgres === true) {
    $colExpr = "{$metaCol}::text AS {$metaCol}";
}
```

Toegepast op:
- `_metadataColumns` SELECT expressies (existing + NULL placeholders)

`$isPostgres` is al gedefinieerd bovenaan `buildUnionSelectPart()` (line 1422)
uit `stripos($platform::class, 'PostgreSQL')` — de fix hergebruikt dat.

## Out of scope voor deze fix

De volgende PG-only expressies in dezelfde method (`buildUnionSelectPart()`)
zijn score-berekeningen die alleen firen bij `_search` param:

- Line 1557: `{$quotedCol}::text ILIKE {$likePattern}` — `ILIKE` is ook PG-only
- Line 1559: `similarity({$quotedCol}::text, {$quotedTerm})` — `similarity()` is een PG extension (pg_trgm)

Deze breken een `?_search=X` query op MariaDB (niet de base list-query die
WOO-520 reporte). Zijn een bredere portability-refactor waard — voorstel:
losse follow-up subtask op WOO-506 die de search-scoring dialect-agnostisch
maakt (bijv. via `MATCH...AGAINST` op MySQL / plaintext LIKE-fallback).

## Test-plan

- [ ] `php -l lib/Db/MagicMapper.php` → clean (verified)
- [ ] Op MariaDB dev-env (nextcloud-docker-dev met `database-mysql`): `GET /apps/opencatalogi/api/search` returnt HTTP 200 met `{"results":[...], "total":N}` in plaats van HTTP 500
- [ ] Op PostgreSQL (Hydra CI): unchanged behavior — `$isPostgres` branch levert nog steeds `col::text` SQL
- [ ] Bestaande PHPUnit-tests voor MagicMapper blijven groen

## Follow-ups

- **`_search` param op MariaDB** — nog steeds broken (ILIKE + similarity()); losse subtask onder WOO-506
- **Andere PG-only spots** (95 hits total in `lib/`) — bredere OR-portability refactor; buiten scope

Closes WOO-520.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Picks up the Files widget upload UX (card chrome on detail pages, upload
default-on, drag-and-drop) and the page-config modal view-mode/map config
(nextcloud-vue beta.168).
….0.0-beta.168' (#339) from chore/ncvue-beta168 into development
…chival-transfer-hardening, dsar-escalation-and-dpia, semantic-object-handoff-engine); branch code superseded by squash merges
Nextcloud 34's app-management page inlines the raw app.svg and recolors
it via 'fill: currentcolor' on the <svg> element. A fill carried on
<path>/<style>/class beats the inherited value, so the icon rendered
white-on-white and vanished from the list. Moving the fill to the <svg>
element lets Nextcloud recolor the icon; visuals are unchanged everywhere
the icon is shown on a dark/colored surface.
…currentcolor recoloring' (#341) from fix/app-icon-svg-level-fill into development
setSelfMetadata() now applies a client-supplied @self.geo onto the object
entity via setGeo(). Unlike organisation/tmlo/folder this is plain
descriptive geographical metadata that drives no security or lifecycle
decision, so it is client-writable; an explicit null clears it. This is
the backend for map widgets (CnObjectGeoWidget) that edit an object's
location via a minimal PATCH of @self.geo only.
…e (map widget backend)' (#343) from feat/object-geo-metadata into development
The published css/index.css re-exported only the unscoped globals, and webpack
pruned the ESM barrel's CSS import as side-effect-free, so any build resolving
the npm dist shipped ZERO scoped SFC CSS. Components rendered data-v-* scope
attributes with no matching rules and silently lost every scoped declaration —
flex containers fell back to display: block. Fixed upstream in nextcloud-vue#171
(css/index.css now @imports the built stylesheet) and #173 (wrapped dashboard
header actions stay right-aligned).

Local builds masked the bug for a long time: they alias @conduction/nextcloud-vue
to the sibling ../nextcloud-vue/src checkout and compile the SFCs (and their
scoped styles) in-app. CI and production have no sibling, so they always resolved
the dist — and always shipped the broken CSS. This app had no way to reproduce
that: `useLocalLib` was hard-wired to `fs.existsSync(localLib)`, so a developer
with a sibling checkout could never build what CI builds. Add the fleet-standard
`USE_LOCAL_LIB=false` escape hatch.
`nextcloud/ocp` is a require-dev STUB package (NC API signatures for Psalm /
PHPStan). It was mapped in the RUNTIME `autoload.psr-4`:

    "OCP\\": "vendor/nextcloud/ocp/OCP/",
    "NCU\\": "vendor/nextcloud/ocp/NCU/"

and lib/AppInfo/Application.php requires vendor/autoload.php — so every request
that touched OpenRegister registered a psr-4 map sending `OCP\*` to the stubs.

The stubs are pinned `^31.0`. On this NC 34 instance that means core's
`OC\Settings\Manager` (which carries `#[\Override]` on `getAdminDelegatedSettings()`)
was compiled against the NC 31 `OCP\Settings\IManager`, which predates that
method. PHP 8.3 raises a COMPILE-TIME fatal:

    OC\Settings\Manager::getAdminDelegatedSettings() has #[\Override] attribute,
    but no matching parent method exists

That takes down the WHOLE instance, not just this app: `occ` dies, `occ app:list`
returns 0 apps, and every /apps/openregister/api/* route 404s while other apps'
endpoints 500. Observed today — pipelinq's dashboard lost all of its KPI and
chart data because the aggregation endpoints were unreachable.

Moves the stub mapping to `autoload-dev`, where static analysis still resolves it
and a `--no-dev` deploy cannot leak it into the runtime autoloader.

NOTE — this is only half the mitigation. `autoload-dev` is STILL baked into the
generated autoloader by a plain `composer install` (dev mode). In this dev
topology the app checkout IS the served app, so a routine `composer install` here
re-bricks the instance. Same landmine in hermiq and launchpad. A durable fix is
either always deploying `--no-dev`, or pinning `nextcloud/ocp` to the running NC
major so a leak is harmless.
…runtime

Moving the stub mapping to `autoload-dev` (previous commit) narrowed the failure
window but did not close it: `autoload-dev` is STILL baked into the generated
autoloader by a plain `composer install`. In this topology the app checkout IS
the served app, so a routine dev install would re-brick the instance — which is
exactly how it broke today.

The mapping turns out to be entirely redundant. Both analysers already discover
the stubs through their own mechanisms, neither of which consults Composer's
autoloader:

  phpstan.neon:  scanDirectories:  - vendor/nextcloud/ocp
  psalm.xml:     <extraFiles><directory name="vendor/nextcloud/ocp" /></extraFiles>
                 plus an explicit <stubs> entry

Verified with the mapping removed:
  - `composer dump-autoload` in DEV mode emits ZERO references to nextcloud/ocp
    in autoload_psr4.php / autoload_static.php → OCP can no longer leak into the
    runtime autoloader under ANY install mode.
  - Psalm: 124 errors, ZERO of which mention OCP. All 96 UndefinedClass errors
    are Doctrine\DBAL\* and Sabre\VObject\* (NC core 3rdparty), pre-existing and
    unrelated. So OCP still resolves for static analysis.

This removes the failure mode rather than narrowing it, and needs no discipline
(`--no-dev`) and no coupling to the running NC major.
…rphans

Deleting a schema that still holds objects was broken in several ways. Diagnosed
live: a "Cow" schema with one object refused to delete (correctly, 409) but the
refusal was unreadable, there was no clean way to delete it, and the UI's
delete-then-unlink ordering corrupted the register on the way past.

## The cascade

`DELETE /api/schemas/{id}?deleteObjects=true` — audit + hard-delete every object,
drop the now-empty magic table(s), delete the schema. Mutually exclusive with
`force` (both -> 400 conflicting-delete-dispositions): force ORPHANS the objects,
deleteObjects REMOVES them; an ambiguous destructive intent is refused rather
than guessed. The no-flag path is byte-for-byte unchanged (409 + objectCount),
and `force` still behaves exactly as before for API clients.

Two phases, because DROP TABLE is DDL and implicitly commits on MySQL/MariaDB
(it cannot be rolled back): phase 1 is one transaction (audit + rows + schema);
phase 2 drops the tables post-commit, best-effort. A failed drop logs a WARNING
and returns success with `tableDropped: false` — it never fails a request whose
data work already succeeded, and never pretends to roll back what it cannot.

A schema can live in MULTIPLE registers, each with its own magic table, and the
guard counts across all of them — so the cascade iterates every one of them.
Dropping only the "current" table would leave behind orphans the guard had
already counted.

## Bugs found and fixed on the way

- `ObjectService::deleteObjectsBySchema()` was a THROWING STUB, so
  `POST /api/bulk/{register}/{schema}/delete-objects` had been returning HTTP 500.
  The MagicMapper method it needed was already fully implemented — it just was
  never wired up. Both bulk routes work again; the misleadingly-named
  `bulk#deleteSchema` (which deletes objects, not a schema) is marked @deprecated.

- `MagicMapper::dropTable()` NEVER WORKED. It called
  `ConnectionAdapter::quoteIdentifier()`, which does not exist on this Nextcloud —
  so it threw "Call to undefined method" on every single call. That is why deleted
  schemas always left their magic table behind. Now quotes via the platform, the
  pattern the rest of the codebase already uses.

- `SchemaMapper::delete()`'s "objects still attached" guard queried the RETIRED
  `openregister_objects` blob table, which is always empty for magic-table
  objects. It counted 0 and waved everything through — a dead guard. Re-pointed at
  the magic tables (direct query: MagicStatisticsHandler injects SchemaMapper, so
  injecting it back would be circular) and given an explicit `force` bypass that
  only the `?force=true` disposition may pass.

  The three callers behind that dead guard now genuinely refuse. Two of them —
  SchemasToolProvider and SchemaTool — are LLM-invokable with no controller in
  front: a model acting on an ambiguous instruction must not be able to orphan
  data. TablesSchemaSyncService also refuses; it retires read-only mirrors whose
  objects live in Nextcloud Tables, so the repaired guard counts 0 and retirement
  proceeds unchanged — and if a magic table DOES hold rows, that is real data the
  sync job has no mandate to destroy.

- `TablesSchemaSyncService::retireMissing()` unlinked a schema from its register
  BEFORE deleting it, and left it unlinked when the delete threw — the exact
  orphaned-schema corruption this change exists to close.

- `BulkController` passed `hardDelete` unnormalized (a query/form request delivers
  the string "true") into a bool-typed parameter: a latent TypeError that would
  have surfaced the moment the stub was fixed.

Live-verified: no-flag 409 unchanged; both flags 400; cascade leaves 0 rows,
0 tables, 0 schema; bulk endpoint 200; 0-object cascade fine.

Tests: 19 new (SchemaDeletionService, SchemaMapper guard, destroy safety).
PHPUnit/Psalm/PHPStan/PHPMD identical to the origin/development baseline — zero
new issues. Also fixed 57 pre-existing PHPCS errors across 8 untouched files.
The previous commit removed the `OCP\` / `NCU\` psr-4 mapping from composer.json,
which is what let the nextcloud/ocp stubs shadow core's OCP and take the whole
instance down. But the mapping was NOT purely redundant, as I first thought: CI
runs PHPUnit standalone on php:8.3-cli with no Nextcloud, and tests/bootstrap.php
loads only vendor/autoload.php — so OCP\* had nowhere else to come from.

Move the registration to the test entry point, where it belongs: a dev-only
mapping in the dev-only bootstrap, instead of in composer.json where a plain
`composer install` bakes it into the autoloader of the SERVED app.

  - CI / standalone: bootstrap registers the stubs when no live NC supplies OCP.
  - Served app: composer's autoloader has no OCP mapping under ANY install mode,
    so it can never shadow core.
  - Static analysis: unaffected — PHPStan reads the stubs via `scanDirectories`,
    Psalm via `<extraFiles>`.

Registered AFTER the Doctrine stubs: IQueryBuilder declares constants referencing
Doctrine\DBAL\ParameterType, evaluated as the file is parsed, so the placeholders
must already be in the class table.

Verified against a CI-identical run (php:8.3-cli, no Nextcloud), before vs after:
  baseline (mapping in composer):  14058 tests, 115 errors, 17 failures
  this change (bootstrap):         14058 tests, 115 errors, 17 failures
Byte-for-byte identical — the pre-existing failures are untouched and nothing
regressed.
…tubs at runtime — they were bricking the whole instance' (#346) from fix/ocp-stubs-not-runtime-autoload into development
…, trust-config repair

Snapshot of uncommitted work that was sitting in the shared dev checkout, committed
so it is captured in git (and pushable) before merging origin/development in. The
working tree is byte-identical before and after this commit — nothing is rewritten.

Contains:
- PublicApiCorsMiddleware registration + #[AnonRateLimit] on object create, and the
  public-create RBAC path in PermissionHandler (+ tests)
- lib/Repair/ImportTrustConfigurationRegister.php (new)
- credential-provider catalogue additions, EditSchema.vue option enrichment

Several of these files had already been re-derived upstream and are byte-identical to
origin/development; the merge that follows reconciles them.
juanclaude-conduction and others added 23 commits July 16, 2026 09:42
…ary drift

CWE-863: getRegisterAuthorization() swallowed \Throwable, returned null
without logging, and cached that null. Callers read empty(null) as 'no rules
configured' and granted FULL permissions -- permanently, for the request.

- Authorization resolvers now throw AuthorizationUnresolvableException and
  never cache a failure as an answer; every caller routes it to a deny.
- getRegisterForSchema() had the same shape (it logged, but logging a
  fail-open does not make it safe) -- also fails closed now.
- MagicRbacHandler clamps to the deny-all predicate instead of 'open to all'.
- Vocabulary: drop phantom x-openregister-seed (no engine); add
  x-openregister-processing (read by ProcessingLogService, was dropped).
- Relocate the 6 MDM trust rules to components.objects so they actually plant.
An unconfigured ContainerInterface mock returned null for RegisterMapper, so
calling a method on it raised an Error the old resolver swallowed into 'open'.
These tests are about custom-scope listener voting; they must not depend on a
fail-open to reach the listener path.
…ift (apply)' (#441) from wip/authz-fail-closed-and-vocabulary-drift into development
…s: done)

Canonical authorization-rbac spec carries the three requirements (synced in
#441): fail-closed authorization resolution, declared seed data is planted,
and the annotation vocabulary contains only engine-backed keys.
…ulary-drift' (#442) from wip/archive-authz-fail-closed into development
…osed-and-vocabulary-drift' (#443) from wip/scratchpad-authz-report into development
…vider

Hermiq needs to run a Claude Max/Pro subscription through the official
`claude` CLI, which is the ToS-sanctioned path: Anthropic categorically
refuses a subscription OAuth token on the direct Messages API (verified
2026-07-16 — HTTP 429 rate_limit_error carrying anthropic-organization-id
but NO retry-after and NO anthropic-ratelimit-* counters, byte-identical
after 14h of zero usage; a refusal, not a quota).

A CLI reads its token from the process ENVIRONMENT. There is no outbound
request for the broker to proxy and no header for injectAuth() to
substitute into, so the constrained-proxy path cannot express this
credential at any host — the host (api.anthropic.com) is perfectly well
known. resolveInjectable() correctly returns null for the host-locked
anthropic-oauth proxy provider, so today Hermiq cannot obtain the token
at all. CredentialController::create() also rejects an unregistered
provider with a 400, so a user cannot even save the credential.

Adds anthropic-cli: inject_only, no baseUrl, no allowRules. Grouped with
the other anthropic providers — both broker branches key on the flag
(isInjectOnly()), never on position, so grouping is editorial and a Claude
provider should not hide inside a block titled 'generic'.

Also generalises $injectOnlyComment. It read as a narrative about
OpenConnector's unbounded hosts and said 'the five entries', so a sixth
inject-only entry elsewhere would silently falsify the reader's inference
that inject_only implies generic-*. inject_only now states the general
rule: the broker cannot bound this call, so it refuses to make it — which
covers both an unbounded host and a non-HTTP consumer.

No PHP: request() already denies inject-only providers and
resolveInjectable() already releases only for them, both strictly after
Guard 1 (owner/IDOR) and Guard 2 (allowedApps). The new entry inherits
correct handling from the flag alone.

Version 1.5.0 -> 1.6.0 (6473d7e already took 1.5.0 for the anthropic
descriptors; the $injectOnlyComment's 1.4.0 narrative is stale).

The trade-off is deliberate and bounded: an inject-only secret leaves
OpenRegister into the trusted same-instance app. Forced (a CLI needs it in
its env), precedented (the five generic-* entries), and bounded (both
guards still run; Doriath keeps custody; app config holds only a
credentialRef). Prefer the zero-knowledge anthropic proxy entry wherever
it works.

Claude Max/Pro is PERSONAL-SCOPE ONLY per the Anthropic ToS — declared
here, enforced by the consuming app which owns the only resolution path.
…al provider' (#445) from feat/anthropic-cli-provider into development
…r trusted in-process callers (#450)

ADR-064 Rule 4 requires an infrastructure credential (a source/consumer
secret) to be organisation-scoped, never personal. But its trusted
in-process consumers — openconnector's migration repair step (Phase C) and
background sync jobs — run with NO user session, and the original design D3
"no session denies" rule left org-scoped infrastructure credentials
unresolvable.

Add a sessionless organisation assertion mirroring the existing actingUserId
posture (honored ONLY without a session; never settable from request input):

- resolveInjectable() gains ?string $actingOrganisationId as its last param,
  threaded through loadAdmittedCredential() into assertOrganisationMember().
- assertOrganisationMember() now admits on two paths: with a session the
  session is authoritative and the asserted value is IGNORED (a request-context
  caller can never escalate via the assertion); sessionless, it admits iff
  actingOrganisationId equals the credential's organisation. Matching keeps
  resolution decoupled from any individual user's membership (the point of org
  scope per ADR-064) — the actingUserId fallback is deliberately not reused.

Trust boundary: resolveInjectable() is NOT HTTP-routed, so the assertion is
only ever set by in-process code. The routed request() passes null explicitly
(comment records the invariant), and CredentialController never reads it —
mirroring how it already refuses to forward actingUserId.

Tests: CredentialBrokerSessionlessOrganisationTest pins the full contract
(match admits, mismatch/null/empty deny, session ignores a wrong/matching
assertion in both directions). Mutation-verified: reverting the sessionless
admit or dropping the === org match makes a test fail. Also fixes a
pre-existing bootstrap gap — DoriathStubs.php (fixtures) was never required by
bootstrap-unit-local.php, so DoriathCredentialStoreTest/CredentialStoreResolverTest
errored; now the full tests/Unit/Service/Credential/ dir is green (105 tests).

Follow-up (not in this change): openconnector's BrokeredCallService must pass
actingOrganisationId when resolving org-scoped credentials sessionlessly.
…-scope resolution for trusted in-process callers (#450)' (#451) from wip/sessionless-org-credential-resolution into development
…dation methods

Gate 6 (orphan-auth) un-blinding (openregister#444) reports 14 orphan
validation methods on origin/development. Triaged each; 4 are superseded by a
proven live enforcement point and are removed (with their dead tests), 10 are
non-auth value-object predicates / deferred opt-in validators left in place and
documented. No method was a live unprotected action — every security-relevant
orphan already had a fail-closed live check.

Deleted (zero lib/src callers, superseding live check verified):
- AuditHandler::validateObjectOwnership (+ private helpers) — LogService::getLogs
  re-validates register/schema inline behind requireAdmin()
- DestructionService::validateDestructionList — DestructionExecutionJob re-checks
  legal holds fail-closed at execution
- SearchQueryHandler::isSearchTrailsEnabled — getEffectiveRecordingMode supersedes
- ValidationHandler::validateSchemaObjects — validateAndSaveObjectsBySchema is the
  live /api/objects/validate path

tests/Unit unchanged: 14763 tests, 27 err/17 fail/23 skip (identical before/after).
) from wip/orphan-auth-remediation into development
Sync the orphan-auth-remediation delta into openspec/specs/ (status: done) and
move the change to changes/archive/2026-07-16-orphan-auth-remediation/.
…453) from wip/orphan-auth-remediation-archive into development
…ontext (_rbac:false)

CredentialBrokerService::mint()'s contract is explicit — "minting is not itself a
guarded operation; authorization is the caller's" (the controller runs the
provider-catalogue + organisation-admin gate before calling; a repair-step
migration is itself system-trusted). But its create saveObject() did NOT pass
_rbac:false, so the write was re-gated by RBAC and ran as whatever principal the
caller had. A SESSIONLESS caller — an occ/repair migration folding an inline
source secret into the broker (openconnector#151) — is the anonymous principal,
which OR denies from creating objects, so mint() threw NotAuthorizedException and
the migration could never complete. The controller path only worked because it
happened to carry an authorized admin session.

This also made mint() inconsistent with itself: its rollback delete already
passes _rbac:false. Add _rbac:false + _multitenancy:false to the create so mint
honours its own contract for every caller, sessionless included.

Live-verified on an isolated NC34 instance: before this change the migration's
mint failed with NotAuthorizedException (secret correctly left intact, Phase D
gate stayed closed); after it, mint→verify→null completes end-to-end
(migrated:1, the source's inline apikey nulled, a credentialRef written, and the
sessionless resolveInjectable round-trip via openregister#450 matched).

The existing mint tests stubbed saveObject() ignoring its arguments, which is why
the gap shipped green — the added test asserts the _rbac:false argument, not the
return value.
…ject in system context (_rbac:false)' (#454) from wip/mint-sessionless-rbac-bypass into development
…nt-less tools

A strict MCP client rejects the entire tools/list when any tool's
inputSchema.properties serialises as a JSON array. An argument-less tool
has an empty properties map, and PHP json_encode renders an empty PHP
array as [] (a JSON array) rather than {} (an object). The Claude CLI's
MCP client fails the whole handshake with:

  Invalid input: expected record, received array
  (at tools.N.inputSchema.properties)

so a single no-parameter tool breaks EVERY tool the server exposes — the
OpenBuild virtual-app builder tools (create schema/page/widget/menu)
included. Verified live: 'claude mcp add --transport http ... /api/mcp'
reported '! Connected - tools fetch failed' before, '✔ Connected' after.

Fix centrally in McpToolsService::listTools() (every provider's descriptors
flow through it): force an empty/absent inputSchema.properties to a stdClass
so it serialises as {}, and default an absent type to 'object'. Non-empty
properties maps are untouched.

Same json_encode([]) trap fixed for the Anthropic provider in hermiq#93.
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.

4 participants