Skip to content

feat(sdk): type the pipe-config write methods from the GraphQL schema - #657

Open
mocha06 wants to merge 1 commit into
devfrom
rc-dev/feat/652-sdk-typed-graphql-inputs
Open

feat(sdk): type the pipe-config write methods from the GraphQL schema#657
mocha06 wants to merge 1 commit into
devfrom
rc-dev/feat/652-sdk-typed-graphql-inputs

Conversation

@mocha06

@mocha06 mocha06 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Closes #652. Based on dev.

Motivation

Pipefy's GraphQL input objects are strictly typed and reject an unknown field themselves:

mutation { updatePipe(input: {id: "303088927", nmae: "x"}) { pipe { id } } }
→ InputObject 'UpdatePipeInput' doesn't accept argument 'nmae'

The SDK was the one layer without those shapes. Twenty-one write operations took **attrs: Any and forwarded whatever they were handed, so a consumer got no autocomplete, no client-side check, and no introspectable signature for exactly the methods where a typo costs a round trip. The MCP tool layer hand-maintains the same shapes a second time to serve its JSON schemas.

What ships

The generator, the schema snapshot, the drift check, and the first batch of seven methods. The remaining fourteen operations are filed as sub-issues of #652 (#653 tables, #654 webhooks and inbox email, #655 automations, #656 relations) so each lands as its own reviewable batch.

The generator

scripts/generate_graphql_inputs.py has three commands, and the split between them is what lets CI check the models without credentials:

Command Reaches the network Run by
snapshot yes a person, after an API change
generate (default) no a person, after snapshot
check no CI, via tests/test_generate_graphql_inputs.py

check catches a hand-edit of a generated file, or a snapshot committed without regenerating. It cannot see the API itself move, because CI has no Pipefy credentials. That half is test_input_types_snapshot_matches_live, marked integration.

Adding a type to ROOT_INPUT_TYPES is the whole of a future batch's codegen step; the transitive closure is resolved from there. Today's seven roots pull in 12 input objects, 108 fields and 2 enums.

packages/sdk/src/pipefy_sdk/graphql_inputs/ holds _generated.py and __init__.py (both written by the generator, both carrying a # @generated header) plus a hand-written _base.py. Everything that needed a decision rather than a mapping lives in _base.py, so a regeneration never overwrites it.

The migrated methods

update_pipe, update_phase, create_phase_field, update_phase_field, update_label, create_field_condition, update_field_condition — on PipeConfigService and on the PipefyClient facade.

from pipefy_sdk.graphql_inputs import UpdatePipeInput

await client.update_pipe(UpdatePipeInput(id=pipe_id, name="Onboarding", color="blue"))

The proven contract

Everything below was read off the live schema by the same introspection the generator runs, and the wire behaviour was checked against the API directly.

Identifier form. ID maps to str | int and is not coerced. That mapping is forced, not chosen: updatePipe accepts {id: 303088927} as an integer (verified), while createFieldCondition answers a string expressions_structure entry with an opaque 500 (documented on normalize_field_condition_payload). Coercing in either direction would break one of the two.

Required despite reading as optional. The schema marks four fields NON_NULL on inputs that look like partial updates, and the models now make that visible rather than leaving it to a docstring:

Input Required
UpdatePhaseInput id, name
UpdatePhaseFieldInput id, label
UpdateLabelInput id, name, color
CreatePhaseFieldInput phase_id, label, type

Unknown fields. extra="forbid" mirrors the API rather than adding a rule on top of it. Nested inputs behave the same way — updatePipe(input: {preferences: {findabl: true}}) answers InputObject 'RepoPreferenceInput' doesn't accept argument 'findabl' — so a nested rejection is not new either. The model only moves both rejections to before the request, where the message can name the field.

Enums are soft. A GraphQL enum is typed str and the documented values are exported as a tuple (COLORS_VALUES), following the CONDITION_OPERATIONS convention already in the SDK. A value added server-side keeps working without an SDK release.

Authorization. Unchanged. Every one of these mutations is governed by the API permission on the caller's credential, exactly as before; nothing here is a permission check.

Behaviour boundaries

  • The payload is unchanged. For all seven methods the typed path builds the same GraphQL input the **attrs merge built, normalizers included. Proved by constructing both and comparing (7/7 identical), which is also what rules the change out as the cause of the two API behaviours in the matrix below.
  • A field condition needs its shape repaired before it is parsed. GraphQL coerces a bare value into a single-item list, so expressions_structure: [0] is a legal way to write [[0]] on the wire — verified: the API stores it as [["0"]]. A model mirroring [[ID]] refuses it, so normalize_field_condition_fields (new, in pipefy_sdk.utils) runs on the raw mapping first, at both boundaries. It is idempotent, and the service still runs the same repair on the serialized payload for a direct SDK caller who skips it.
  • The CLI keeps its reserved-key guard. Deleting the SDK's **attrs guards would have let --extra '{"phaseId": "999"}' overwrite the PHASE_ID argument and file the rule on another phase, and --extra '{"id": ...}' patch a different condition. reject_reserved_extra_keys restores the refusal the SDK used to raise, at the layer that now owns it. The MCP tools were already covered by their own reserved sets.
  • A resolution hint is a parameter, not a field. update_phase_field takes phase_id / pipe_id as keyword arguments. The mutation has no such fields; they narrow the SDK's slug-to-uuid lookup. The MCP tool and the CLI lift them out of extra_input / --extra rather than rejecting them, so both paths that worked before still work.
  • The reserved-key guards moved rather than vanished. They lived in the SDK because **attrs sat beside a positional argument that the same key could override. One typed input leaves the SDK nothing to reconcile, so the check now sits at the boundary that still has two sources: the CLI's --extra and the MCP tools' extra_input.
  • No explicit null. A field left unset is omitted, so a partial update stays partial; no field can be sent as an explicit null. That was true of **attrs too.
  • The MCP and CLI surfaces do not move. No tool name, argument shape, annotation, envelope, or CLI option changes, and the CLI help golden is unchanged. The one observable difference is that an unknown extra_input key or nested preferences key now comes back as INVALID_ARGUMENTS naming the field, instead of as the API's rejection one round trip later. The rejected value is never echoed back, so a wrong field carrying a secret stays out of the transcript and the shell log.

Docs and tests

  • docs/sdk/README.md — a "Typed mutation inputs" section with the worked example, the ID rule, the soft-enum rule, and the resolution-hint rule.
  • packages/sdk/README.md — how to regenerate, and what check can and cannot catch.
  • .gitignorescripts/* is ignored behind an allowlist; the generator is added to it.
  • New tests: tests/test_generate_graphql_inputs.py (33), packages/sdk/tests/test_graphql_inputs.py (19), packages/sdk/tests/services/pipefy/test_input_types_snapshot_integration.py (1, integration), four MCP cases (the new rejection, the lifted hint, the nested path, the repaired shape) and three CLI cases (both reserved-key guards and the repaired shape).

Testing

Offline: full suite green, ruff check, ruff format --check, lint-imports (2 contracts kept), bump_version.py verify, the skill-reference and Cursor-plugin linters, uv build --all-packages. The built pipefy wheel carries graphql_inputs/; the snapshot JSON stays out of it, being a dev artifact.

The live drift check ran too: a fresh snapshot against the API reproduces the committed file byte for byte, and check still passes against it.

The runs below drove the pipefy-mcp-server binary built from this branch, over the real MCP protocol on stdio, against a prod organization. Every phase, field, label and condition was created and deleted by the runs.

# Scenario Expected Observed Verdict
0 Server built from this branch serves the surface the seven migrated tools registered 187 tools, all seven present PASS
1 update_pipe name success success=True PASS
2 update_pipe preferences.findabl INVALID_ARGUMENTS naming preferences.findabl, no request Invalid arguments for update_pipe: 'preferences.findabl' is not an accepted field. PASS
3 create_phase (setup) a phase id 344145761 PASS
4 update_phase description, name resolved by the tool success success=True PASS
5 create_phase_field select with options a field slug prioridade_652_19482 PASS
6 create_phase_field extra_input.requred INVALID_ARGUMENTS naming requred, no field created 'requred' is not an accepted field PASS
7 update_phase_field slug + phase_id hint success success=True PASS
8 update_phase_field hint inside extra_input success, hint lifted rather than sent success=True PASS
9 update_phase_field extra_input.requred INVALID_ARGUMENTS naming requred 'requred' is not an accepted field PASS
10 create_label (setup) a label id 318030991 PASS
11 update_label with a 3-digit hex colour success, normalized to #RRGGBB success=True PASS
12 update_label extra_input.colour INVALID_ARGUMENTS naming colour 'colour' is not an accepted field PASS
13 create_phase_field target (setup) an internal_id 433626970 PASS
14 create_field_condition with integer expressions_structure created; the API places it on the Start form, which the tool reports id=307275623 code=FIELD_CONDITION_WRONG_PHASE actual_phase=319036221 PASS
15 update_field_condition name and actions success success=True PASS
16 update_field_condition extra_input.nmae INVALID_ARGUMENTS naming nmae 'nmae' is not an accepted field PASS
17 delete_field_condition, preview then confirm deleted success=True PASS
18 delete_label, preview then confirm deleted success=True PASS
19 delete_phase, cascading both fields (teardown) deleted success=True PASS

Two rows deserve their reasoning written down, because both look like regressions and neither is.

Row 14. createFieldCondition ignores phaseId for a freshly created phase and files the rule under the pipe's Start form. Sending the identical payload through raw GraphQL, with no SDK or MCP layer involved, reproduces it: requested 344145760, actual 319036221 ("Start form"). This is the behaviour the tool's _verify_created_field_condition step and its FIELD_CONDITION_WRONG_PHASE code already exist to catch, so the row records the toolkit reporting an API behaviour correctly.

Row 15. An update carrying only name is rejected with Validation failed: Condicionais devem ter pelo menos uma ação. The API requires actions on every field-condition update, whatever the schema's nullability says. The scenario sends them.

One known mismatch is left alone deliberately: update_phase_field's options argument is annotated list[Any] | dict[str, Any] | None, while UpdatePhaseFieldInput.options is [String]. Neither a dict nor a non-string list was ever usable — no code path handled them and the API refused both — so the effect of this PR is that they now fail before the request instead of after. Narrowing the annotation would change the tool's JSON schema, which this PR otherwise does not touch, so it belongs in its own change.

Not verified: mcp.pipefy.com. The hosted deployment pins an exact published release, so it cannot exercise this branch. The SDK is the only layer that changes shape, and it ships in the same wheel.

@mocha06
mocha06 changed the base branch from rc-dev/refactor/649-sdk-destructive-confirmation to dev September 2, 2026 16:16
Pipefy's GraphQL input objects reject an unknown field themselves
(InputObject 'UpdatePipeInput' doesn't accept argument 'nmae'), on nested
inputs too. The SDK was the one layer without those shapes: 21 write
operations took **attrs: Any and forwarded whatever they were handed, so a
consumer got no autocomplete, no client-side check, and no introspectable
signature for exactly the methods where a typo costs a round trip.

Add scripts/generate_graphql_inputs.py, which writes pipefy_sdk.graphql_inputs
from a committed snapshot of the schema. Its three commands split on whether
they reach the network: snapshot introspects the live API, generate rewrites
the models from the snapshot, and check regenerates in memory and fails on a
difference. Only check runs in CI, which has no Pipefy credentials; catching
the API itself move is test_input_types_snapshot_matches_live, marked
integration. Adding a type to ROOT_INPUT_TYPES is the whole of a later batch's
codegen step, since the closure is resolved from there.

Migrate the pipe-configuration batch: update_pipe, update_phase,
create_phase_field, update_phase_field, update_label, create_field_condition
and update_field_condition, on PipeConfigService and the PipefyClient facade.
The model carries the id, because UpdatePipeInput.id is ID! in the schema, so
update_pipe(pipe_id, name=...) becomes update_pipe(UpdatePipeInput(id=...,
name=...)). The remaining 14 operations are tracked as sub-issues of #652 and
land one batch at a time.

Three mappings needed a decision rather than a translation, and they live in
the hand-written _base.py so a regeneration cannot overwrite them:

ID maps to str | int and is not coerced. updatePipe takes an integer id, but
createFieldCondition answers a string expressions_structure entry with an
opaque 500, so coercing either way would break one of the two.

A GraphQL enum is typed str with its documented values exported as a tuple,
following the CONDITION_OPERATIONS convention, so a value added server-side
works without an SDK release.

extra="forbid" mirrors the API rather than adding a rule on top of it. The
model only moves the rejection to before the request, where it can name the
field; the offending value is never echoed back, so a wrong field carrying a
secret stays out of a transcript or a shell log.

Two shapes still need repairing before the input is parsed, because the models
mirror the schema exactly. GraphQL coerces a bare value into a single-item
list, so expressions_structure: [0] is a legal way to write [[0]] on the wire
and a model typed from [[ID]] would refuse it; normalize_field_condition_fields
runs on the raw mapping at both boundaries and is idempotent, so the service's
own pass still covers a direct SDK caller. The reserved-key guards move rather
than vanish: they lived in the SDK because **attrs sat beside a positional
argument the same key could override, and they now sit at the CLI boundary,
which still has two sources.

phase_id and pipe_id stay keyword arguments on update_phase_field. The mutation
has no such fields; they narrow the SDK's slug-to-uuid lookup. The MCP tool and
the CLI lift them out of extra_input and --extra rather than rejecting them.

The wire payload does not move: for all seven methods the typed path builds the
same GraphQL input the **attrs merge built, normalizers included. No MCP tool
name, argument shape, envelope, or CLI option changes.

Closes #652.

Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com>
@mocha06
mocha06 force-pushed the rc-dev/feat/652-sdk-typed-graphql-inputs branch from 17bc882 to 6a10baf Compare September 2, 2026 16:22
@mocha06
mocha06 requested a review from gbrlcustodio September 2, 2026 16:49
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