From 3c52b955e83e6a7ee876ba9fc28d5fe33bd199c2 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 14 Jul 2026 01:36:36 +0100 Subject: [PATCH 1/3] Vendor the unreleased server spec and regenerate types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec carries the branch-merge delete_branch request field, the branch_deleted / branch_delete_error response fields, and the read_set_conflict / recovery_required structured error fields — all on the server's main branch but not yet in a tagged release, so check-drift stays red until the next release is pinned (see the PR checklist). --- packages/sdk/src/generated/index.ts | 2 + packages/sdk/src/generated/types.gen.ts | 82 ++++++++++- spec/openapi.json | 185 +++++++++++++++++++++++- 3 files changed, 261 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/generated/index.ts b/packages/sdk/src/generated/index.ts index 5d2e26b..5ff202a 100644 --- a/packages/sdk/src/generated/index.ts +++ b/packages/sdk/src/generated/index.ts @@ -132,7 +132,9 @@ export { type QueryRequest, type ReadOutput, type ReadRequest, + type ReadSetConflictOutput, type ReadTargetOutput, + type RecoveryRequiredOutput, type SchemaApplyOutput, type SchemaApplyRequest, type SchemaOutput, diff --git a/packages/sdk/src/generated/types.gen.ts b/packages/sdk/src/generated/types.gen.ts index 0baf192..eedf7f1 100644 --- a/packages/sdk/src/generated/types.gen.ts +++ b/packages/sdk/src/generated/types.gen.ts @@ -43,12 +43,31 @@ export type BranchMergeOutcome = export type BranchMergeOutput = { actor_id?: string | null; + /** + * Why the requested source-branch deletion did not happen. Present iff + * `branch_deleted` is `false`. + */ + branch_delete_error?: string | null; + /** + * Result of the requested post-merge source-branch deletion. Absent when + * `delete_branch` was not requested; `true` when the source branch was + * deleted; `false` when the deletion was refused or failed (the merge + * itself still succeeded — see `branch_delete_error`). + */ + branch_deleted?: boolean | null; outcome: BranchMergeOutcome; source: string; target: string; }; export type BranchMergeRequest = { + /** + * Delete the source branch after a successful merge. The deletion runs + * under its own `branch_delete` policy check; a refusal or failure is + * reported via `branch_deleted` / `branch_delete_error` on the response + * and never fails the already-landed merge. + */ + delete_branch?: boolean; /** * Source branch whose commits will be merged. */ @@ -126,6 +145,8 @@ export type ErrorOutput = { error: string; manifest_conflict?: null | ManifestConflictOutput; merge_conflicts?: Array; + read_set_conflict?: null | ReadSetConflictOutput; + recovery_required?: null | RecoveryRequiredOutput; }; export type ExportRequest = { @@ -443,11 +464,26 @@ export type ReadRequest = { snapshot?: string | null; }; +/** + * Structured authority mismatch for an RFC-022 prepared write. Values are + * strings because members include optional graph commit ids and future + * authority tokens, not only numeric table versions. + */ +export type ReadSetConflictOutput = { + actual?: string | null; + expected?: string | null; + member: string; +}; + export type ReadTargetOutput = { branch?: string | null; snapshot?: string | null; }; +export type RecoveryRequiredOutput = { + operation_id: string; +}; + export type SchemaApplyOutput = { applied: boolean; manifest_version: number; @@ -597,6 +633,10 @@ export type ClusterCreateBranchErrors = { * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; + /** + * An overlapping durable recovery intent must be resolved before retry + */ + 503: ErrorOutput; }; export type ClusterCreateBranchError = @@ -645,6 +685,10 @@ export type ClusterMergeBranchesErrors = { * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; + /** + * An overlapping durable recovery intent must be resolved before retry + */ + 503: ErrorOutput; }; export type ClusterMergeBranchesError = @@ -693,6 +737,10 @@ export type ClusterDeleteBranchErrors = { * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; + /** + * An overlapping durable recovery intent must be resolved before retry + */ + 503: ErrorOutput; }; export type ClusterDeleteBranchError = @@ -734,13 +782,17 @@ export type ClusterChangeErrors = { */ 403: ErrorOutput; /** - * Merge conflict + * Write-authority conflict */ 409: ErrorOutput; /** * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; + /** + * An overlapping durable recovery intent must be resolved before retry + */ + 503: ErrorOutput; }; export type ClusterChangeError = ClusterChangeErrors[keyof ClusterChangeErrors]; @@ -898,10 +950,18 @@ export type ClusterIngestErrors = { * Forbidden */ 403: ErrorOutput; + /** + * Prepared load authority changed before effects + */ + 409: ErrorOutput; /** * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; + /** + * An overlapping durable recovery intent must be resolved before retry + */ + 503: ErrorOutput; }; export type ClusterIngestError = ClusterIngestErrors[keyof ClusterIngestErrors]; @@ -941,10 +1001,18 @@ export type ClusterLoadErrors = { * Forbidden */ 403: ErrorOutput; + /** + * Prepared load authority changed before effects + */ + 409: ErrorOutput; /** * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; + /** + * An overlapping durable recovery intent must be resolved before retry + */ + 503: ErrorOutput; }; export type ClusterLoadError = ClusterLoadErrors[keyof ClusterLoadErrors]; @@ -985,13 +1053,17 @@ export type ClusterMutateErrors = { */ 403: ErrorOutput; /** - * Merge conflict + * Write-authority conflict */ 409: ErrorOutput; /** * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; + /** + * An overlapping durable recovery intent must be resolved before retry + */ + 503: ErrorOutput; }; export type ClusterMutateError = ClusterMutateErrors[keyof ClusterMutateErrors]; @@ -1076,7 +1148,7 @@ export type ClusterInvokeQueryErrors = { */ 404: ErrorOutput; /** - * Merge conflict + * Stored mutation write-authority conflict */ 409: ErrorOutput; /** @@ -1087,6 +1159,10 @@ export type ClusterInvokeQueryErrors = { * Policy evaluation error (a denial is reported as 404, not 500) */ 500: ErrorOutput; + /** + * A stored mutation is blocked by a durable recovery intent + */ + 503: ErrorOutput; }; export type ClusterInvokeQueryError = diff --git a/spec/openapi.json b/spec/openapi.json index df532f3..12d50ca 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -212,6 +212,16 @@ } } } + }, + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "security": [ @@ -227,7 +237,7 @@ "branches" ], "summary": "Merge one branch into another.", - "description": "Merges `source` into `target` (defaults to `main`). Outcome is one of\n`already_up_to_date`, `fast_forward`, or `merged`. Returns 409 with the\nlist of conflicts if the merge cannot be completed; the target is left\nunchanged in that case. **Destructive** to `target` on success.", + "description": "Merges `source` into `target` (defaults to `main`). Outcome is one of\n`already_up_to_date`, `fast_forward`, or `merged`. Returns 409 with the\nlist of conflicts if the merge cannot be completed; the target is left\nunchanged in that case. **Destructive** to `target` on success.\n\nWith `delete_branch: true` the source branch is deleted after a successful\nmerge, under its own `branch_delete` policy check. The merge is durable by\nthen, so a deletion refusal or failure never fails the request; it is\nreported via `branch_deleted: false` + `branch_delete_error`.", "operationId": "cluster_mergeBranches", "parameters": [ { @@ -310,6 +320,16 @@ } } } + }, + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "security": [ @@ -397,6 +417,16 @@ } } } + }, + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "security": [ @@ -477,7 +507,7 @@ } }, "409": { - "description": "Merge conflict", + "description": "Write-authority conflict", "content": { "application/json": { "schema": { @@ -495,6 +525,16 @@ } } } + }, + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "deprecated": true, @@ -795,6 +835,16 @@ } } }, + "409": { + "description": "Prepared load authority changed before effects", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, "429": { "description": "Per-actor admission cap exceeded; honor `Retry-After` header", "content": { @@ -804,6 +854,16 @@ } } } + }, + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "deprecated": true, @@ -884,6 +944,16 @@ } } }, + "409": { + "description": "Prepared load authority changed before effects", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, "429": { "description": "Per-actor admission cap exceeded; honor `Retry-After` header", "content": { @@ -893,6 +963,16 @@ } } } + }, + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "security": [ @@ -908,7 +988,7 @@ "mutations" ], "summary": "Apply a GQ mutation to a branch (canonical mutation endpoint).", - "description": "Writes to the named `branch` (defaults to `main`). Mutations are atomic\nper call and produce a new commit. Returns counts of nodes and edges\naffected. **Destructive**: on success the branch is updated; rejected\nmutations may still acquire locks briefly. Returns 409 on merge conflict.\n\nPairs with `POST /query` (read-only). The legacy `POST /change` route\nhas identical semantics and is kept as a deprecated alias.", + "description": "Writes to the named `branch` (defaults to `main`). Mutations are atomic\nper call and produce a new commit. Returns counts of nodes and edges\naffected. **Destructive**: on success the branch is updated; rejected\nmutations may still acquire locks briefly. Returns 409 when the prepared\nwrite authority changes before effects.\n\nPairs with `POST /query` (read-only). The legacy `POST /change` route\nhas identical semantics and is kept as a deprecated alias.", "operationId": "cluster_mutate", "parameters": [ { @@ -973,7 +1053,7 @@ } }, "409": { - "description": "Merge conflict", + "description": "Write-authority conflict", "content": { "application/json": { "schema": { @@ -991,6 +1071,16 @@ } } } + }, + "503": { + "description": "An overlapping durable recovery intent must be resolved before retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "security": [ @@ -1154,7 +1244,7 @@ } }, "409": { - "description": "Merge conflict", + "description": "Stored mutation write-authority conflict", "content": { "application/json": { "schema": { @@ -1182,6 +1272,16 @@ } } } + }, + "503": { + "description": "A stored mutation is blocked by a durable recovery intent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } } }, "security": [ @@ -1699,6 +1799,20 @@ "null" ] }, + "branch_delete_error": { + "type": [ + "string", + "null" + ], + "description": "Why the requested source-branch deletion did not happen. Present iff\n`branch_deleted` is `false`." + }, + "branch_deleted": { + "type": [ + "boolean", + "null" + ], + "description": "Result of the requested post-merge source-branch deletion. Absent when\n`delete_branch` was not requested; `true` when the source branch was\ndeleted; `false` when the deletion was refused or failed (the merge\nitself still succeeded — see `branch_delete_error`)." + }, "outcome": { "$ref": "#/components/schemas/BranchMergeOutcome" }, @@ -1716,6 +1830,10 @@ "source" ], "properties": { + "delete_branch": { + "type": "boolean", + "description": "Delete the source branch after a successful merge. The deletion runs\nunder its own `branch_delete` policy check; a refusal or failure is\nreported via `branch_deleted` / `branch_delete_error` on the response\nand never fails the already-landed merge." + }, "source": { "type": "string", "description": "Source branch whose commits will be merged." @@ -1900,6 +2018,28 @@ "items": { "$ref": "#/components/schemas/MergeConflictOutput" } + }, + "read_set_conflict": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ReadSetConflictOutput", + "description": "Set when a prepared write's logical authority changed before effects." + } + ] + }, + "recovery_required": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RecoveryRequiredOutput", + "description": "Set when an overlapping durable recovery intent must be resolved before\nretry. Its table effects may or may not have started." + } + ] } } }, @@ -2413,6 +2553,30 @@ } } }, + "ReadSetConflictOutput": { + "type": "object", + "description": "Structured authority mismatch for an RFC-022 prepared write. Values are\nstrings because members include optional graph commit ids and future\nauthority tokens, not only numeric table versions.", + "required": [ + "member" + ], + "properties": { + "actual": { + "type": [ + "string", + "null" + ] + }, + "expected": { + "type": [ + "string", + "null" + ] + }, + "member": { + "type": "string" + } + } + }, "ReadTargetOutput": { "type": "object", "properties": { @@ -2430,6 +2594,17 @@ } } }, + "RecoveryRequiredOutput": { + "type": "object", + "required": [ + "operation_id" + ], + "properties": { + "operation_id": { + "type": "string" + } + } + }, "SchemaApplyOutput": { "type": "object", "required": [ From 4cce28bfeec08f4b14fd52963504cd1551df45cb Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 14 Jul 2026 01:36:37 +0100 Subject: [PATCH 2/3] Surface the new structured errors and merge deletion in the SDK ConflictError gains readSetConflict (the coarse OCC read-set rejection, alongside the existing manifestConflict), and a code-less 503 with a recovery_required body maps to a typed ServiceUnavailableError carrying the blocking recovery operationId. branches.merge() documents deleteBranch and its non-fatal branchDeleted / branchDeleteError result fields; the types flow from the regenerated spec. --- packages/sdk/src/errors.ts | 36 +++++++++++++++++++- packages/sdk/src/index.ts | 3 ++ packages/sdk/src/resources/branches.ts | 6 ++++ packages/sdk/src/types.ts | 4 +++ packages/sdk/test/branches.test.ts | 28 ++++++++++++++++ packages/sdk/test/errors.test.ts | 46 ++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index db8b67d..de4005b 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -1,4 +1,11 @@ -import type { ErrorCode, ErrorOutput, ManifestConflict, MergeConflict } from './types'; +import type { + ErrorCode, + ErrorOutput, + ManifestConflict, + MergeConflict, + ReadSetConflict, + RecoveryRequired, +} from './types'; export interface OmnigraphErrorContext { status: number; @@ -44,17 +51,43 @@ export class ConflictError extends OmnigraphError { * Refresh and retry. */ readonly manifestConflict?: ManifestConflict; + /** + * Set when the conflict is a coarse read-set rejection: `member` (a branch + * identifier, graph head, or schema identity the write was prepared + * against) changed between preparation and commit. Refresh and retry. + */ + readonly readSetConflict?: ReadSetConflict; constructor(ctx: OmnigraphErrorContext) { super(ctx); const body = ctx.body as ErrorOutput | undefined; this.mergeConflicts = body?.mergeConflicts; this.manifestConflict = body?.manifestConflict ?? undefined; + this.readSetConflict = body?.readSetConflict ?? undefined; } } export class TooManyRequestsError extends OmnigraphError {} export class InternalServerError extends OmnigraphError {} + +/** + * 503 with a `recovery_required` body: a durable recovery intent overlaps the + * requested operation and must be resolved (typically by the server's next + * write or a read-write reopen) before a retry can succeed. Carries the + * blocking recovery `operationId`. The body deliberately has no `code` — + * `ErrorCode` is a closed wire contract, so the meaning rides in the + * structured field. + */ +export class ServiceUnavailableError extends OmnigraphError { + readonly recoveryRequired?: RecoveryRequired; + + constructor(ctx: OmnigraphErrorContext) { + super(ctx); + const body = ctx.body as ErrorOutput | undefined; + this.recoveryRequired = body?.recoveryRequired ?? undefined; + } +} + export class NetworkError extends OmnigraphError {} /** @@ -88,6 +121,7 @@ const statusToClass: Record Omnigrap 409: ConflictError, 429: TooManyRequestsError, 500: InternalServerError, + 503: ServiceUnavailableError, }; export function fromResponse(args: { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2763248..dccdc02 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -22,6 +22,7 @@ export { ConflictError, TooManyRequestsError, InternalServerError, + ServiceUnavailableError, NetworkError, ConfigurationError, } from './errors'; @@ -67,6 +68,8 @@ export type { // Conflict / errors / shared MergeConflict, ManifestConflict, + ReadSetConflict, + RecoveryRequired, ErrorOutput, // Utility Camelize, diff --git a/packages/sdk/src/resources/branches.ts b/packages/sdk/src/resources/branches.ts index b16f70c..2a2a950 100644 --- a/packages/sdk/src/resources/branches.ts +++ b/packages/sdk/src/resources/branches.ts @@ -37,6 +37,12 @@ export class BranchesResource { * Merge `source` into `target` (defaults to `main`). * * **Idempotency**: a retry on the same merge yields `outcome: "already_up_to_date"`. + * + * **Cleanup**: `deleteBranch: true` deletes `source` after the merge lands + * (requires `branch_delete` permission in addition to `branch_merge`). A + * deletion refusal never fails the call — the merge is durable by then — + * so check `branchDeleted` / `branchDeleteError` on the result. It runs on + * every successful outcome, including `already_up_to_date`. */ merge(input: BranchMergeInput, opts: CallOptions = {}): Promise { return this.t.request('POST', '/branches/merge', { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 118b4f5..71ba610 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -35,7 +35,9 @@ import type { QueryCatalogEntry as QueryCatalogEntryOutput, QueryRequest, ReadOutput, + ReadSetConflictOutput, ReadTargetOutput, + RecoveryRequiredOutput, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, @@ -81,6 +83,8 @@ export type Snapshot = Camelize; export type SnapshotTable = Camelize; export type MergeConflict = Camelize; export type ManifestConflict = Camelize; +export type ReadSetConflict = Camelize; +export type RecoveryRequired = Camelize; // Inputs (requests): camelCase from the caller, converted to snake_case on the wire. export type BranchCreateInput = Camelize; diff --git a/packages/sdk/test/branches.test.ts b/packages/sdk/test/branches.test.ts index 09d4ad2..ceb2363 100644 --- a/packages/sdk/test/branches.test.ts +++ b/packages/sdk/test/branches.test.ts @@ -44,6 +44,34 @@ describe('branches resource', () => { expect(r.outcome).toBe('fast_forward'); }); + it('merge with deleteBranch sends delete_branch and camelizes the delete result', async () => { + const { fetch, calls } = stubFetch({ + body: { + actor_id: null, + outcome: 'fast_forward', + source: 'feature', + target: 'main', + branch_deleted: false, + branch_delete_error: "descendant branch 'feature-child' still depends on it", + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); + const r = await og.branches.merge({ + source: 'feature', + target: 'main', + deleteBranch: true, + }); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ + source: 'feature', + target: 'main', + delete_branch: true, + }); + // The merge landed; the refused deletion is data, not an error. + expect(r.outcome).toBe('fast_forward'); + expect(r.branchDeleted).toBe(false); + expect(r.branchDeleteError).toContain('feature-child'); + }); + it('delete escapes the branch name in path', async () => { const { fetch, calls } = stubFetch({ body: { actor_id: null, name: 'a b/c', uri: 's3://x' }, diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index 23049dd..1dd6287 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -6,6 +6,7 @@ import Omnigraph, { InternalServerError, MethodNotAllowedError, NotFoundError, + ServiceUnavailableError, TooManyRequestsError, UnauthorizedError, } from '../src'; @@ -63,6 +64,51 @@ describe('error dispatcher', () => { } }); + it('ConflictError exposes readSetConflict when present', async () => { + const { fetch } = stubFetch({ + status: 409, + body: { + error: 'read set changed', + code: 'conflict', + read_set_conflict: { member: 'graph_head:main', expected: '01A', actual: '01B' }, + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); + try { + await og.mutate({ query: 'insert Person { name: "x" }' }); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ConflictError); + const err = e as ConflictError; + expect(err.readSetConflict).toEqual({ + member: 'graph_head:main', + expected: '01A', + actual: '01B', + }); + } + }); + + it('maps a code-less 503 with recovery_required to ServiceUnavailableError', async () => { + // The 503 body deliberately has no `code` (ErrorCode is a closed wire + // contract), so the mapping must come from the status alone. + const { fetch } = stubFetch({ + status: 503, + body: { + error: 'a durable recovery intent overlaps this write', + recovery_required: { operation_id: '01JRECOVERY' }, + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); + try { + await og.mutate({ query: 'insert Person { name: "x" }' }); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ServiceUnavailableError); + const err = e as ServiceUnavailableError; + expect(err.recoveryRequired).toEqual({ operationId: '01JRECOVERY' }); + } + }); + it('ConflictError exposes mergeConflicts when present', async () => { const { fetch } = stubFetch({ status: 409, From ee4ca14e2c30b72e9186dce7b21a7048c65b2b15 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 14 Jul 2026 01:36:37 +0100 Subject: [PATCH 3/3] Add deleteBranch to the branches_merge MCP tool The tool passes deleteBranch through to the SDK and the workflow instructions collapse the branch loop's merge + delete steps into one branches_merge call; a deletion refusal is reported in the result, never as a tool error. The stub transport gains a /branches/merge route so the passthrough is pinned. --- packages/mcp/src/server.ts | 14 ++++++++++---- packages/mcp/test/server.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index bd315bb..bf693dd 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -41,7 +41,7 @@ Workflow norms (violating these breaks things or silently corrupts data): 4. \`load mode: "merge"\` upserts by @key (idempotent — use this for at-least-once pipelines). \`"overwrite"\` truncates the branch. \`"append"\` fails on key collision. 5. Verify every write. \`commits_list\` head BEFORE and AFTER. If identical, the write did not land. 504s do not mean failure — the server may have committed after the proxy dropped the response. 6. Append-only types (Signal, Claim, Decision, Event, Interaction, Policy, Outcome, MarketingElement) duplicate on blind retry. Pointer types (Org, Person, Opportunity, Channel, Actor, ActionItem, Artifact, Meeting, Technology, Campaign, UseCase) dedupe via @key. -7. Risky/large writes: \`branches_create\` from main → \`load\` onto the branch → verify → \`branches_merge\` → \`branches_delete\`. +7. Risky/large writes: \`branches_create\` from main → \`load\` onto the branch → verify → \`branches_merge\` with \`deleteBranch: true\` (merges and cleans up the branch in one step; a deletion refusal is reported in the result, never fails the merge). 8. Schema is read-only over this MCP. \`schema_get\` returns the active .pg source; there is no \`schema_apply\` tool. A cluster-managed graph rejects HTTP schema apply (409) — schema changes go through \`omnigraph cluster apply\` (an operator/CLI action), not an agent tool. Date format: ISO strings on \`mutate\` params; integer days-since-epoch in load JSONL \`Date\` fields. \`DateTime\` is ISO on both. @@ -323,15 +323,21 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { { title: 'Merge branch', description: - 'Merge `source` into `target` (default `main`). Idempotent: re-merging an already-merged branch yields outcome=already_up_to_date.', + 'Merge `source` into `target` (default `main`). Idempotent: re-merging an already-merged branch yields outcome=already_up_to_date. ' + + 'Pass deleteBranch=true to delete the source branch after the merge lands — a deletion refusal is reported via branchDeleted/branchDeleteError in the result, never as an error.', inputSchema: { source: z.string().min(1), target: z.string().optional(), + deleteBranch: z.boolean().optional(), }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, }, - async ({ source, target }) => { - const r = await og.branches.merge({ source, target: target ?? defaultBranch }); + async ({ source, target, deleteBranch }) => { + const r = await og.branches.merge({ + source, + target: target ?? defaultBranch, + deleteBranch, + }); return { content: jsonText(r) }; }, ); diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 8fb8050..e94b2c9 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -38,6 +38,20 @@ function fakeFetch(): typeof globalThis.fetch { if (method === 'GET' && path === '/branches') { return respond(200, { branches: ['main', 'feature'] }); } + if (method === 'POST' && path === '/branches/merge') { + const body = JSON.parse(String(init?.body ?? '{}')) as { + source: string; + target?: string; + delete_branch?: boolean; + }; + return respond(200, { + actor_id: null, + outcome: 'already_up_to_date', + source: body.source, + target: body.target ?? 'main', + ...(body.delete_branch ? { branch_deleted: true } : {}), + }); + } if (method === 'GET' && path === '/schema') { return respond(200, { schema_source: 'node Person { name: String @key }' }); } @@ -138,6 +152,18 @@ describe('omnigraph-mcp server', () => { expect(parsed.sdkServerVersion).toBe('0.8.1'); }); + it('branches_merge passes deleteBranch through and returns the delete result', async () => { + const { client } = await setup(); + const r = await client.callTool({ + name: 'branches_merge', + arguments: { source: 'feature', target: 'main', deleteBranch: true }, + }); + const block = (r.content as Array<{ type: string; text: string }>)[0]!; + const parsed = JSON.parse(block.text); + expect(parsed.outcome).toBe('already_up_to_date'); + expect(parsed.branchDeleted).toBe(true); + }); + it('calls the query tool and preserves opaque param keys', async () => { const { client } = await setup(); const r = await client.callTool({