This is the tool Codex CLI calls when it wants a change set on GitHub or GitLab, as osFoundry's MCP server lists it to any client, signed in or not:
{"name": "propose_gitnest_provider_publication",
"description": "Ask the repository owner to review a composed GitNest change set, push only its dedicated branch using their linked Git provider account, and open a draft pull request. Approval rechecks permissions, risk policy, required checks, and target commit.",
"inputSchema": {"properties": {
"repository_id": {"type": "string"},
"change_set_id": {"type": "string"},
"expected_change_set_version": {"type": "integer", "minimum": 1},
"expected_composition_oid": {"type": "string"},
"idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}},
"required": ["repository_id", "change_set_id", "expected_change_set_version",
"expected_composition_oid", "idempotency_key"],
"additionalProperties": false},
"annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Look at what isn't there. No branch name, no title, no target branch, no draft flag, no token. The client names a change set and two facts it observed about it, and everything else comes from the owner's side when the owner approves. "Push only its dedicated branch" is the promise this post takes apart. It also covers the tool that usually comes first, propose_gitnest_file_import, and what its 200 KB budget means for real files.
No codex binary is installed on this Mac, so the client side is a Python script, run here (Apple M1, Python 3.12 via uv, git 2.39.5). Network numbers were measured from Japan.
$ uv run --with tiktoken --with jsonschema python3 publish_sim.py
== Part 1: live tools/list (no token) ==
tools/list x5 median 505.7 ms; 59 tools, 13 GitNest
read_gitnest_repository osfoundry:read ro=True openWorld=False destructive=False
propose_gitnest_file_import osfoundry:write ro=False openWorld=False destructive=True
propose_gitnest_native_publication osfoundry:write ro=False openWorld=True destructive=True
propose_gitnest_provider_publication osfoundry:write ro=False openWorld=True destructive=True
these four = 989 of 12662 tokens
(Required-field lists trimmed from the output above. Tokens are cl100k_base, so approximate for Codex's own tokenizer.) The four tools cost under 1,000 tokens of the list's 12,662. A Codex config that names them in enabled_tools keeps the other 55 out of the prompt.
Why are both publication tools "open world" and the import isn't?
openWorldHint tells the client whether a tool reaches beyond a closed system. The file import writes into a GitNest repository that only the project can see. A pull request, even a native one, is a thing other people get notified about and act on. Codex uses annotations like these to decide when to stop and ask, so the server's hints are the first line of the approval chain, before osFoundry's own card.
What can a file import carry?
The schema answers most of it. Twelve cases against the live inputSchemas, validated with jsonschema (Draft 2020-12):
file_import 20 files ok
file_import 21 files REJECT maxItems
file_import one file 100,000 chars ok
file_import one file 100,001 chars REJECT maxLength
file_import file with mode '100755' REJECT additionalProperties
file_import delete: {'path','delete':true} REJECT required
file_import idempotency_key 7 chars REJECT minLength
native_publication full pin ok
native_publication no expected_composition_oid REJECT required
provider_publication expected_change_set_version 0 REJECT minimum
provider_publication extra 'draft': false REJECT additionalProperties
provider_publication extra 'target_branch' REJECT additionalProperties
A file entry is path plus content and nothing else. No mode bits, so a script lands non-executable. No deletes, because a delete would need an entry without content. Every entry adds or replaces a file with its full text, which is also exactly what the owner reads on the approval card.
One limit the schema can't express is the 200 KB total from the description. maxLength counts characters. The budget counts UTF-8 bytes:
JA file: 100,000 chars passes maxLength; UTF-8 = 300,000 bytes
A Japanese file at the per-file character cap is already one and a half times the whole commit's budget. A client that checks only the schema will build calls the server has to refuse. Count bytes on the client.
How many approval cards does a real import become?
Part 3 packs real directories into import calls under 20 files and 200 KB, first-fit by size, and counts the proposals. Each proposal is one card the owner has to open:
GitNest web lib (.ts) 25 files 126,756 chars 127,210 bytes (1.00 B/char) | 2 proposals
per proposal (files, KB): 20/115, 5/12
GitNest web components (.tsx) 22 files 274,206 chars 274,819 bytes (1.00 B/char) | 2 proposals
per proposal (files, KB): 10/200, 12/75
Team 2 JA articles (.md) 55 files 359,213 chars 662,285 bytes (1.84 B/char) | 4 proposals
per proposal (files, KB): 13/200, 16/196, 18/194, 8/73
The corpora are osFoundry's own frontend sources and this blog's Japanese drafts, read straight off disk. Small source files hit the 20-file cap first. Big components and non-Latin text hit the byte budget first. Reading "KB" as 1,000 or 1,024 bytes changed nothing for these three sets.
The other thing to notice is the commit count. Four proposals means four commits on the branch, approved one by one, any of which the owner may decline. If the files only make sense together, say so in each commit message, or keep the import small enough for one card.
What does "push only its dedicated branch" mean in git?
osFoundry's server-side provider push isn't public. The desktop app's own push, used by GitNest's Push Change Set Branch button, is: gitnest_push_change_set_branch in gitnest_desktop.rs. It takes the values a publication preflight returned and does this, in order:
- Reads
origin's fetch URL and push URL and refuses if they point at different repositories, or not at the provider, owner and repo the preflight named. - Requires the branch to be exactly
osfoundry/change-set-<id>, with<id>a UUID. - Requires the marker to be exactly
GitNest-Change-Set: <id>. - Requires the composition OID to be 4 to 64 hex characters, then
git cat-file -e <oid>^{commit}. - Runs
git update-ref refs/heads/<branch> <oid> <old>, where<old>is what the ref held when it was read, or empty if it didn't exist. - Pushes one refspec:
git push origin refs/heads/<branch>:refs/heads/<branch>.
The script rebuilds that against a bare repository standing in for GitHub. The clone has two things the owner has not pushed: a local branch wip/secret-pricing and an extra commit on release/2.4. The composed commit is built without a worktree (a throwaway index, hash-object, update-index --cacheinfo, write-tree, commit-tree):
remote refs before: 3 (dana/refactor-cart, main, release/2.4)
`git push --all` would change 2 remote refs: ['release/2.4', 'wip/secret-pricing']
explicit refspec push changed 1 ref: osfoundry/change-set-5b0c2e8e-3f6a-4d2b-9a71-0c4e9d1f7a23 -> 73e59f27bc4f
untouched: True; marker in pushed commit: True
That second line is the whole reason for the explicit refspec. A convenient push from the owner's clone would have published an unreviewed hotfix and a private branch along with the change set. The explicit refspec moves one ref, whose name is derived from the change set ID, so it can't collide with anyone's branch.
What gets refused?
branch name 'codex/ci' instead of contract refused: publication branch does not match the preflight contract
marker for another change set refused: publication marker does not match the preflight contract
OID 'HEAD' instead of hex refused: composition OID must be a hex commit id
hex OID that is not in the clone refused: fatal: Not a valid object name deadbeefdeadbeefdeadbeefdeadbeefdeadbeef^{commit}
update-ref with a stale expected old value exit 128: fatal: update_ref failed for ref 'refs/heads/osfoundry/change-set-5b0c2e8e-3f6a-
push URL differs from fetch URL refused: origin fetch and push remotes point at different repositories
The "HEAD" case looks pedantic, but it isn't. A symbolic name resolves to whatever is checked out at push time, which is not necessarily what was composed and reviewed. A hex OID can only mean one commit. The stale update-ref is git's own compare-and-swap. If the local branch moved between read and write, the write fails instead of silently replacing it. The split URL check closes a quieter hole: git remote set-url --push can point pushes somewhere other than where fetches come from, and the code requires both to name the same repository.
What does the approval recheck add?
The git steps protect the push. The two expected_ fields protect the decision. Part 5 reconstructs the recheck from the tool descriptions ("Approval rechecks permissions, risk policy, required checks, and target commit"):
as proposed publish
recomposed to v4 before the click refused: change set is v4, card says v3
lint re-ran and failed refused: required checks not passed: ['lint']
Codex read version 3 and commit 73e59f27bc4f…, and the owner's card shows both. If someone recomposes the change set in the meantime, approving the card would publish something nobody looked at, so the proposal fails. The version alone isn't enough, because two compositions of the same version could differ. The OID alone isn't enough either, because it says nothing about which edit of the change set the owner meant. Pinning both costs the client two fields.
What does it cost?
20 runs, local bare remote: compose median 114.2 ms, checks+update-ref+push median 140.0 ms (max 430.0)
The six checks and the push add about 140 ms on a local bare remote. Against a real provider, the network round trip dominates. The whole script, 5 live tools/list calls and 21 publications included, took 11.7 s wall-clock and 3.5 s of user CPU.
If you wire this up yourself
- Let the agent name the change set and what it observed (version and commit), never the branch, title, target or token. Derive the branch name from the change set ID.
- Push with one explicit refspec. Never
--all,--mirroror a baregit pushfrom a clone a person also uses. - Write the local ref with an expected old value, and require fetch and push URLs to name the same repository.
- Accept only hex OIDs for anything that will be published. Resolve symbolic names before the review, not after.
- State the import budget in bytes and check it on the client. Character limits in the schema won't catch non-Latin text.
- Mark tools that reach other people with
openWorldHint: true, so the client's own approval prompt fires before yours.
How change sets, checks and publication sit together in a repository is on the GitNest page, and the protocol side is in the MCP glossary entry.
Top comments (0)