Skip to content

MCPL 0.5 + RFC-003: channels capability object, capability paths, ManifestTracker - #6

Merged
antra-tess merged 3 commits into
mainfrom
mcpl-0.5/capabilities-and-manifest
Aug 3, 2026
Merged

MCPL 0.5 + RFC-003: channels capability object, capability paths, ManifestTracker#6
antra-tess merged 3 commits into
mainfrom
mcpl-0.5/capabilities-and-manifest

Conversation

@antra-tess

Copy link
Copy Markdown
Contributor

Implements MCPL 0.5.0-draft and RFC-003 in one branch. Closes #4 and #5
they are done together because #5's canonical digest covers the manifest #4
reshapes, and #5 says so ("Depends on the channels capability object from #4").

Branched off origin/main. package-lock.json had pre-existing local
modifications that are not mine; it is untouched and uncommitted.


#4 — capability advertisement

Item Done
1. channels is an object ChannelsCap = register / lifecycle / publish / incoming / streaming / acknowledge / typing (SPEC §14.1). channels.streaming is now declarable — it was unreachable while the member was typed boolean.
2. uses is the capability-path vocabulary CAPABILITY_PATHS is the closed §6.2 / App. B.2 set of 17 paths; FeatureSetDeclaration.uses is CapabilityPath[]. validateUses fails closed — absent, empty, or containing an unrecognised value is invalid_uses (§6.4, §6.6).
3. context/afterInference removed Gone, with modifiedResponse and the blocking form. InferenceLifecycleParams (§10.5) is metadata-only: inferenceId, conversationId, turnIndex, phase, optional model/usage. No content fields exist.
4. FeatureSetDeclaration description required; hostState dropped (removed in 0.4.1); scoped dropped (§7 removed). featureSets is an object keyed by name (§6.1) — this is a breaking type change and it broke test/connection.test.ts, which is updated in the same commit.
5. §14.4 channels context field Moved to ContextBeforeInferenceParams, where the spec puts it.
6. Negotiated policy FeatureSetsUpdateParams gains effectiveCapabilities / deniedCapabilities; the result is the §6.7 degradation receipt (accepted / mode / unavailableFeatures[], or accepted: false with fallback: "mcp-only" | "close").
7. Out-of-spec surface state/* and branches/* are carried unchanged and commented as out of scope for 0.5.0, pending the separate decision the issue asks for. channels/acknowledge and channels/typing are promoted into §14 and now have capability paths.

Recursive advertisement (§5.1). The walk is generic over a tree derived
from CAPABILITY_PATHS, not a hardcoded key set — §5.4 calls a hardcoded set of
nestable keys non-conforming "since the vocabulary is depth 3 and will grow".
true at any level is shorthand for every leaf beneath it; false and absence
advertise nothing. An unrecognised member cannot mint a capability.

Absence is denial. capabilityGranted returns false for an empty,
undefined or null grant and never consults a deny-list.
conflictingCapabilityEntries implements §5.4's requirement that a path in both
effectiveCapabilities and deniedCapabilities makes the message malformed.

inference/lifecycle is documented as best-effort, per §10.5 as amended:
consumers MUST dedupe terminals on inferenceId and MUST keep a safety timeout.
No replay, outbox, acknowledgement or event identity is implied anywhere.

#5 — ManifestTracker (RFC-003 / SPEC §17)

src/manifest.ts:

  • manifestDigest — §17.2 exactly: "sha256:" + base64url_unpadded(SHA-256(JCS(manifest_without_revision))).
    revision is stripped from the root object only; version, false and
    null members are content. Set-valued arrays (featureSets.*.uses,
    tagOntology.coreTags, tags.*.implies) are deduped and sorted by UTF-8
    byte order
    — not Array.prototype.sort(), which is UTF-16 and diverges above
    U+FFFF. keyed.*.values and every other array keep their order.
  • changedDomainscapabilities (every member but version, revision,
    featureSets), featureSets (excluding tagOntology within it), tagOntology.
  • ManifestTrackersetManifest(next) canonicalizes, digests, diffs,
    installs atomically, and emits per connection. transaction() coalesces N
    related edits into one announcement. attach(conn, { announcedRevision })
    seeds from the initialize handshake so a fresh connection does not fire a
    redundant announcement immediately after initialize already carried the
    manifest. handleManifestRequest() answers from the same snapshot.
  • mcpl/manifestChanged carries revision + domains and nothing else —
    no diff, no added/removed list, no policy conclusion. mcpl/manifest
    returns the complete current experimental.mcpl object, never a delta.
  • The server side generates no resident-facing prose and no policy
    conclusions
    (RFC-003 §12). ChangeImpact / ChangeDisposition are exported
    for hosts and are documented as host-derived.

Interop note: the advertisement is FLAT

There is no capabilities wrapper object anywhere. SPEC §5.1 and §17.1 are
explicit ("capability members at the top level, with featureSets as one member
among them. There is no nested capabilities wrapper"). An earlier RFC-003 draft
invented one; it was wrong. A test asserts mcpl/manifest's result has no
capabilities member.

Conformance vectors

test/vectors/manifest-digest-vectors.json is a verbatim copy of
anima-research/mcplconformance/manifest-digest-vectors.json, branch
mcpl-0.5/rfc-003-conformance-vectors, commit 8d9c0bd
(sha256 1337968815fc3e817a8b92cb656525d16d1902a20bb43bc496b3989adbcf71fd).
Not edited here — per conformance/CONSUMING.md §8, a vector believed wrong is
an issue against that repo, not a local fix.

test/manifest.test.ts iterates the file rather than transcribing it, and
asserts canonicalJson, sha256Hex and digest separately so a failure
localizes to canonicalization vs. encoding vs. base64url, plus sameDigestAs /
differentDigestFrom and the four sortVectors.

All 20 manifest vectors (15 positive, 5 negative) and all 4 comparator vectors
pass.
That includes unicode-and-json-escapes, number-canonicalisation,
set-sort-is-utf8-byte-order, nested-featuresets-and-list-arrays,
dot-in-capability-member-name, both boolean-shorthand vectors, both
null/false vectors, and utf8-vs-utf16-divergence-above-bmp.

$ npm run typecheck   # clean
$ npm test            # 83/83 pass
$ npm run build       # clean

Point at a live checkout with
MCPL_DIGEST_VECTORS=/path/to/mcpl/conformance/manifest-digest-vectors.json npm test.


⚠️ ORDERING: channels.streaming is now declarable

#4 item 1 warns that making channels.streaming declarable activates a
production double-post in discord-mcpl
, whose outgoing/complete handler
calls sendMessage() directly. That fix is in another repository and is not
in this PR
. Nothing in this library can send it — a host still has to grant
channels.streaming — but fix anima-research/discord-mcpl before granting
it to a Discord server.
SPEC §14.5 is quoted in the ChannelsOutgoingComplete
doc comment: delivery is never a side effect of a lifecycle event, and a
server-side send triggered by it is a conformance defect.

What I did NOT do, and why

  1. discord-mcpl's outgoing/complete double-post. Different repository,
    explicitly out of scope for this issue. Flagged above.
  2. Removing state/* and branches/*. MCPL 0.5: channels capability object, capability paths, inference/lifecycle #4 item 7 asks for a separate
    decision
    , not a removal. They are carried unchanged with comments marking
    them out of scope. hostState and scoped, which the issue does ask to
    drop, are dropped.
  3. Inventing a channels/typing param shape. §14 promotes the method and
    its capability but specifies no params beyond the channel it targets, so
    ChannelsTypingParams has channelId only. Guessing a state/duration
    field would have been fabrication.
  4. Host-side manifest processing (§17.5–§17.7). This is the server-side
    library. Validation ordering, the change receipt, and grant recomputation are
    host work; ChangeImpact / ChangeDisposition are exported so a host can
    type against them.
  5. A release. Nothing is npm-published from this branch; version stays
    0.2.2. featureSets going from array to object is breaking and wants a
    deliberate major/minor decision.
  6. Touching package-lock.json. Pre-existing local modification, not mine.

Open questions (answered fail-closed)

  1. Who enforces §17.2's [A-Za-z0-9._:*-], and what happens on violation?
    The spec states the MUST and names no actor. Chosen: the digest function
    refuses
    (ManifestDigestError, code identifier_charset), matching the
    frozen vectors' errorCodes. This is stricter than §6.4's invalid_uses,
    which degrades one feature set and still yields a revision — the two failures
    are deliberately distinct and there is a test for that.
  2. Are capability member names validated inside arrays? A capability path is
    a chain of object member names, so the recursive validator descends plain
    objects at every depth (catching
    contextHooks.beforeInference.inject.before user) but does not descend
    into arrays under a capability member, where "path segment" has no meaning.
    No vector covers it.
  3. set_member_not_string / manifest_not_object. Named in the vectors'
    errorCodes but exercised by no vector. Implemented as errors, fail-closed.
  4. inferenceRequest: true. Read as §5.1 shorthand for every leaf, so it
    advertises inferenceRequest.streaming too. A server that does not stream
    should say { streaming: false }.
  5. The vectors' own README §5 lists ten questions RFC-003 does not settle
    (recursive-vs-root revision stripping, shorthand expansion, set-ness
    scoping, tagsAny/tagsAll/tagsNone as lists, …). This implementation
    follows the frozen vectors on every one of them, so it and
    Anarchid/mcpl-core move together if the RFC later answers differently.

I found no vector I disagree with. The one process note: the vectors were
read from anima-research/mcpl branch mcpl-0.5/rfc-003-conformance-vectors
because conformance/ does not exist on main yet. Merging that branch would
make the path in #5 resolve.

Closes #4
Closes #5

…acker

Implements MCPL 0.5.0-draft and RFC-003 (SPEC §17). Closes the two issues
together because #5's digest covers the manifest #4 reshapes.

#4 — capability advertisement

- `channels` is an OBJECT (§14.1): register / lifecycle / publish / incoming /
  streaming / acknowledge / typing. `channels.streaming` was previously
  UNDECLARABLE because the member was typed `boolean`.
- `CAPABILITY_PATHS` is the closed §6.2 / App. B.2 vocabulary and `uses` is
  typed against it. `validateUses` fails closed: absent, empty, or containing an
  unrecognised value is `invalid_uses` (§6.4, §6.6).
- Advertisement is walked RECURSIVELY (§5.1) from a tree derived from the path
  vocabulary, not a hardcoded key set — §5.4 calls hardcoded nestable keys
  non-conforming. `true` at any level is shorthand for every leaf beneath it.
- `effectiveCapabilities` is the sole normative allowlist; `capabilityGranted`
  returns false for an empty/absent grant and never consults a deny-list.
  `conflictingCapabilityEntries` implements §5.4's fail-closed rejection.
- `context/afterInference`, `modifiedResponse` and the blocking hook form are
  gone; `inference/lifecycle` (§10.5) replaces them — metadata only, and
  documented as BEST-EFFORT: dedupe terminals by `inferenceId`, keep a timeout.
- §14.4 channel context moved onto `context/beforeInference`, where the spec
  puts it.
- `FeatureSetDeclaration`: `description` required, `hostState` and `scoped`
  dropped. `featureSets` is an object keyed by name (§6.1).
- `featureSets/update` typed dual-mode with the §6.7 degradation receipt.

#5 — ManifestTracker (RFC-003 / §17)

- `manifestDigest` implements §17.2 exactly: JCS (RFC 8785) over the manifest
  with the ROOT `revision` removed, set-semantic arrays deduped and sorted by
  UTF-8 byte order, SHA-256, unpadded base64url.
- `ManifestTracker.setManifest(next)` canonicalizes, digests, diffs to derive
  changed domains, installs atomically, emits per connection and answers
  `mcpl/manifest` from the same snapshot. `transaction()` coalesces N edits into
  one announcement. `attach({announcedRevision})` seeds from the handshake so a
  fresh connection does not fire a redundant announcement after initialize.
- `mcpl/manifestChanged` carries revision + domains and NO payload; `mcpl/manifest`
  returns the COMPLETE flat `experimental.mcpl` object, never a delta.
- The digest REFUSES on an identifier outside `[A-Za-z0-9._:*-]`
  (`ManifestDigestError`), rather than emitting a revision two libraries could
  disagree about. Distinct from §6.4 `invalid_uses`, which still yields one.
- No resident-facing prose or policy conclusions are generated server-side
  (RFC-003 §12).

Interop: the advertisement is FLAT — no `capabilities` wrapper object. An
earlier RFC-003 draft invented one; SPEC §5.1 and §17.1 are authoritative.

Tests: `test/vectors/manifest-digest-vectors.json` is a VERBATIM copy of the
frozen interop vectors from anima-research/mcpl
`conformance/manifest-digest-vectors.json` (branch
`mcpl-0.5/rfc-003-conformance-vectors`, commit 8d9c0bd). All 20 manifest
vectors (15 positive, 5 negative) and all 4 set-comparator vectors pass, with
canonical string, SHA-256 hex and digest asserted separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 2, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@antra-tess

Copy link
Copy Markdown
Contributor Author

Interop differ results — changes required before merge. A differential harness ran both this branch and Anarchid/mcpl-core#3 through 46 manifests, 16 advertisement objects, 13 grant patterns, and 8 diff pairs. 41/46, 16/16, 8/13, 6/8 agree. The divergences and the adjudicated direction:

1. Trailing-* wildcard — this library changes. capabilities.ts:287 makes a final * match all deeper segments (contextHooks.* grants depth-4 injection leaves). The Rust library, and every server that hit the question (heartbeat, tavern, xgate), read * as exactly one segment, segment counts equal. SPEC §5.4 will be amended to pin the one-segment reading: it's the deny-safe direction, and a suffix wildcard silently widens the grant class the whole of §5.4 exists to narrow. Please change capabilityPatternMatches to require equal segment counts.

2. Set-declared field of wrong JSON type — this library's behaviour becomes normative. Hashing a non-array uses verbatim (manifest.ts:309) is correct: the digest stays total, set semantics apply only when the value is an array, and two libraries can agree on verbatim bytes. Rust's refusal (set_field_not_array) will be dropped on their side. A pinning vector will be added to the frozen file.

3. Missing API — featureSets/update → grant. There is no fromUpdate-equivalent, so consumers hand-roll the one message-to-grant step this library exists to own. Required semantics (SPEC amendment incoming): Request form with absent effectiveCapabilities = grant of nothing (absence is denial, §5.4 — anything else leaves a stale wider grant standing); Notification form never alters the grant except applying disabled reductions; neither establishes ready except an answered Request.

4. sendError needs a data param. Two server PRs (portal#11, heartbeat#4) could not emit §6.6's documented data:{capability} / data:{featureSet} and each worked around it differently.

Confirmed clean, for the record: capability path strings character-identical with Rust; four-phase lifecycle vocab identical, both sides explicitly best-effort with dedupe+timeout; channels object identical 7 leaves; receipt types here are correct (fallback required on accepted:false — the Rust side changes to match); manifestChanged emit site carries exactly {revision, domains}.

Four changes pinned by the 2026-08-02 SPEC amendment (mcpl e869744) and
the review on PR #6:

1. §5.4 wildcard: `*` matches exactly one segment and segment counts
   must be equal — a trailing `*` is no longer a subtree match.
   `contextHooks.*` now grants none of the depth-4 injection leaves;
   tests assert exactly that.

2. featureSets/update → grant: new `grantFromUpdate(previous, params,
   form)` owns the message-to-grant step. Request form: absent
   `effectiveCapabilities` is a grant of NOTHING (absence is denial,
   never "no change"); `enabled` is an allowlist only when present;
   `disabled` always subtracts; conflicts fail closed. Notification
   form: never alters the grant except `disabled` reductions, never
   establishes ready; other grant fields are discarded with a
   diagnostic. Plus `CapabilityGrantState`, `emptyGrantState`,
   `featureSetSelected`.

3. sendError gains an optional `data` param so servers can emit §6.6's
   documented `data:{capability}` / `data:{featureSet}` shapes;
   `RpcError` now carries `error.data` through to the requesting side.

4. Re-vendor conformance vectors from mcpl main a77be49 (23 vectors +
   4 comparators, byte-identical), adding the three adjudication pins:
   wrong-typed set fields and array-form featureSets hash VERBATIM
   (already this library's behavior — now covered), and empty-vs-absent
   members digest differently. Added a changedDomains test pinning that
   featureSets appearing announces featureSets + tagOntology (§17.3).

typecheck clean; 98/98 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Contributor Author

{"body": "REVISE \u2014 four material interop/security findings at exact head 896958c2131acb319d8c9c77fa54a4faa4f0aee2.\n\nIndependent suite is green (98/98, typecheck/build), and the shared 23-vector blob matches mcpl main byte-for-byte. These cases are outside the frozen vectors:\n\n1. tools can be minted from the wrong handshake object. advertisedCapabilities({version:\"0.5\", tools:true}) returns tools, and advertisedCapabilitiesFromInitialize therefore grants the advertisement even when outer MCP capabilities.tools is absent. SPEC \u00a75.1 places tools only in standard MCP capabilities; the experimental manifest cannot self-advertise it. Build the MCPL advertisement tree without PATHS_NOT_ADVERTISED_IN_MCPL and add tools only from outer initialize capabilities. Current runtime proof: outer absent, nested tools \u2192 ['tools'].\n\n2. Legal grant wildcards are rejected by the public type. FeatureSetsUpdateParams.effectiveCapabilities and deniedCapabilities are typed CapabilityPath[], but \u00a75.4 permits entries such as channels.*. Use a distinct CapabilityPattern/string[] carrier while keeping FeatureSetDeclaration.uses closed to exact CapabilityPath[].\n\n3. The digest still refuses a malformed set array despite \u00a717.2\u2019s totality rule. uses:[1] and uses:[\"tools\",1] throw set_member_not_string. Current SPEC says the only digest refusal is identifier_charset; other non-conforming structures are hashed verbatim and fail later validation. If a named set field is not an all-string array, leave that array untouched for JCS. Remove set_member_not_string as a digest failure.\n\n4. Domain diff conflates absent and explicit null. changedDomains({version:\"0.5\"},{version:\"0.5\",featureSets:null}) returns [] in TS, while Rust correctly returns featureSets + tagOntology. \u00a717.3 says appearance/disappearance is a change. Do not use null as the sentinel for absent; retain presence separately.\n\nPlease add shared conformance coverage for non-string set-array totality and absent-vs-null domain appearance. Findings 1 and 3 also affect Rust; I am filing those on its PR separately.\n"}

1. `tools` is never minted from the experimental manifest (SPEC §5.1):
   the advertisement tree is built without PATHS_NOT_ADVERTISED_IN_MCPL,
   and `advertisedCapabilitiesFromInitialize` adds `tools` only from the
   outer standard MCP `capabilities.tools`. Runtime proof reversed:
   outer absent, nested tools → [].

2. Grant fields carry `CapabilityPattern` (plain string), so legal §5.4
   wildcards like `channels.*` typecheck in `FeatureSetsUpdateParams.
   effectiveCapabilities`/`deniedCapabilities`, while
   `FeatureSetDeclaration.uses` stays closed to exact `CapabilityPath`s.

3. Digest totality for malformed set arrays (adjudicated 2026-08-03): a
   set-declared array containing ANY non-string member is hashed
   VERBATIM — no sort, no dedupe, no identifier check.
   `set_member_not_string` is removed as a digest failure. Vector file
   re-vendored byte-for-byte from mcpl main 2cdc7fb (25 vectors, incl.
   non-string-set-member-hashed-verbatim / -single); all pass.

4. Domain diff tracks presence separately from value (§17.3): absent vs
   explicit `null` `featureSets` is a change — `changedDomains` on the
   review probe now returns ['featureSets','tagOntology'] both ways.
   `null` is no longer the internal absence sentinel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Contributor Author

ACCEPT at exact head c46b7e3f8a0f1677a95c33cca9a940e7c03ff68e.

Independent re-review:

  • typecheck, build, and 103/103 tests pass;
  • nested experimental tools:true no longer mints standard MCP authority; outer MCP tools does;
  • grant fields now carry legal CapabilityPattern wildcards while feature-set uses remains exact-path typed;
  • malformed set arrays with non-string members hash verbatim, while all-string invalid identifiers still fail identifier_charset;
  • absent, explicit null, and empty object are presence-distinct in domain diffing; the exact featureSets probes now agree with Rust;
  • vendored 25-vector corpus is byte-identical to mcpl main 2cdc7fb (SHA-256 b4318cb5f2e47fc640509d814536a55166d68be7938903c91e4552a7cc6f0e90).

No remaining blocker found.

@antra-tess
antra-tess merged commit 14cd573 into main Aug 3, 2026
2 checks passed
@antra-tess
antra-tess deleted the mcpl-0.5/capabilities-and-manifest branch August 3, 2026 03:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant