update_project
Update specific fields on an existing Capsule project. Change name, status, owner, team, stage, custom fields, or expected close date without affecting other data.
Instructions
Update fields on an existing project. Only the fields you provide are changed. Use status='CLOSED' to close a project. CLOSED projects remain fully editable — Capsule does not enforce closed-record immutability. Stage moves and description edits on a CLOSED project are accepted without warning.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| id | Yes | ||
| name | No | ||
| description | No | ||
| status | No | ||
| ownerId | No | Reassign owner: pass a user ID to set, or `null` to unassign (matches the 'Unassign' option in Capsule's web UI). When you supply `ownerId` and omit `teamId` and/or `stageId`, the connector fetches the project's current omitted fields and includes them in the PUT body — this preserves them across the owner change (without it, Capsule's PUT would clear team; stage carry is defensive against the symmetric clear). Supply `teamId` and/or `stageId` explicitly on the same call to change them instead. `teamId: null` clears the team as part of an owner change. Constraints (Capsule enforces, 422 on violation): owner must be a member of the team if both are set; a project must always have at least one of {owner, team} set (cannot clear both). | |
| teamId | No | Reassign team: pass a team ID (discover via list_teams) to set, or `null` to unassign. Capsule preserves the existing owner across a team change (server-side), so `update_project { teamId }` alone is safe — the owner is carried through. Owner must be a member of the new team or Capsule returns 422 'owner is not a member of the team'. A project must always have at least one of {owner, team} set — `teamId: null` on a project whose owner is already null returns 422 'owner or team is required'. | |
| stageId | No | Move the project to this stage (board column). Discover IDs via list_stages. Owner and team are preserved across stage-only updates (Capsule's PUT semantic). WARNING (cross-board): Capsule does NOT validate that the new stage belongs to the project's current board — passing a stageId from a different board silently relocates the project across boards. Team and other board-derived defaults are NOT updated to match the new board. Verify against the project's current board (read the project first, list its board's stages) before passing a cross-board id. | |
| expectedCloseOn | No | YYYY-MM-DD | |
| fields | No | Set custom field values on this record. PARTIAL UPDATE: only the definitions you list are touched; any field NOT in this array is left unchanged. Discover available definitions via list_custom_fields; read current values via get_project with embed='fields'. Project-specific: setting a field whose definition lives under a 'data tag' populates the row's internal tagId but does NOT auto-add the data tag to the project's tags array — use add_tag explicitly if you want it visible via embed=tags. |
Implementation Reference
- src/tools/projects.ts:183-234 (handler)The handler function `updateProject` that executes the tool logic. It builds a PUT body, handles the asymmetric Capsule owner/team semantic by fetching current values when only ownerId is provided, and calls capsulePut at /kases/{id}.
export async function updateProject(input: z.infer<typeof updateProjectSchema>) { const { id, ownerId, teamId, stageId, fields, ...rest } = input; const body: Record<string, unknown> = {}; for (const [k, v] of Object.entries(rest)) { if (v !== undefined) body[k] = v; } // Capsule's PUT on /kases has an asymmetric owner/team semantic: // // `owner` in body → Capsule clears `team` (unless `team` also in body) // `team` in body → Capsule preserves the existing `owner` (and validates owner ∈ team) // // To make `update_project { ownerId }` safe (so it doesn't accidentally // clear an existing team — or, defensively, stage), the connector reads // the current project and carries any omitted `team` / `stage` into the // PUT body whenever `ownerId` is being touched. Carrying stage is // defensive: alpha.20-era verification didn't directly probe whether // owner-in-body PUTs clear stage the way they clear team, but the cost // of a redundant `stage: <currentId>` is one extra integer in the body, // so we err on the safe side rather than risk a silent stage clear. // // `null` means "unassign" on either owner/team (matches Capsule's UI // "Unassign" option); `undefined` means "don't touch this field". let resolvedTeamId: number | null | undefined = teamId; let resolvedStageId: number | undefined = stageId; if (ownerId !== undefined && (teamId === undefined || stageId === undefined)) { const { data } = await capsuleGet<{ kase: { team?: { id: number } | null; stage?: { id: number } | null }; }>(`/kases/${id}`); // Only carry forward when the project actually has a value; if // current team/stage is null, leave the field out of the body entirely // (sending `team: null` would be a redundant clear and could surprise // on the owner-or-team-required 422 path; same idea for stage). if (teamId === undefined) { resolvedTeamId = data.kase?.team?.id ?? undefined; } if (stageId === undefined) { resolvedStageId = data.kase?.stage?.id ?? undefined; } } if (ownerId === null) body["owner"] = null; else if (ownerId !== undefined) body["owner"] = { id: ownerId }; if (resolvedTeamId === null) body["team"] = null; else if (resolvedTeamId !== undefined) body["team"] = { id: resolvedTeamId }; if (resolvedStageId) body["stage"] = resolvedStageId; const mappedFields = mapFieldsForBody(fields); if (mappedFields !== undefined) body["fields"] = mappedFields; return capsulePut<{ kase: unknown }>(`/kases/${id}`, { kase: body }); } - src/tools/projects.ts:131-181 (schema)Zod schema `updateProjectSchema` defining input validation for the tool. Fields: id (required), name, description, status (OPEN/CLOSED), ownerId (nullable), teamId (nullable), stageId, expectedCloseOn (YYYY-MM-DD), and fields (custom fields array).
export const updateProjectSchema = z.object({ id: z.number().int().positive(), name: z.string().min(1).optional(), description: z.string().optional(), status: z.enum(["OPEN", "CLOSED"]).optional(), ownerId: z .number() .int() .positive() .nullable() .optional() .describe( "Reassign owner: pass a user ID to set, or `null` to unassign (matches the 'Unassign' option in Capsule's web UI). " + "When you supply `ownerId` and omit `teamId` and/or `stageId`, the connector fetches the project's current omitted fields and includes them in the PUT body — this preserves them across the owner change (without it, Capsule's PUT would clear team; stage carry is defensive against the symmetric clear). " + "Supply `teamId` and/or `stageId` explicitly on the same call to change them instead. `teamId: null` clears the team as part of an owner change. " + "Constraints (Capsule enforces, 422 on violation): owner must be a member of the team if both are set; a project must always have at least one of {owner, team} set (cannot clear both).", ), teamId: z .number() .int() .positive() .nullable() .optional() .describe( "Reassign team: pass a team ID (discover via list_teams) to set, or `null` to unassign. " + "Capsule preserves the existing owner across a team change (server-side), so `update_project { teamId }` alone is safe — the owner is carried through. " + "Owner must be a member of the new team or Capsule returns 422 'owner is not a member of the team'. " + "A project must always have at least one of {owner, team} set — `teamId: null` on a project whose owner is already null returns 422 'owner or team is required'.", ), stageId: z .number() .int() .positive() .optional() .describe( "Move the project to this stage (board column). Discover IDs via list_stages. Owner and team are preserved across stage-only updates (Capsule's PUT semantic). " + "WARNING (cross-board): Capsule does NOT validate that the new stage belongs to the project's current board — passing a stageId from a different board silently relocates the project across boards. Team and other board-derived defaults are NOT updated to match the new board. Verify against the project's current board (read the project first, list its board's stages) before passing a cross-board id.", ), expectedCloseOn: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .describe("YYYY-MM-DD"), fields: z .array(CustomFieldWriteSchema) .optional() .describe( fieldsArrayDescriptor("get_project") + " Project-specific: setting a field whose definition lives under a 'data tag' populates the row's internal tagId but does NOT auto-add the data tag to the project's tags array — use add_tag explicitly if you want it visible via embed=tags.", ), }); - src/server.ts:535-541 (registration)Registration of the tool named 'update_project' via the `registerTool` helper in src/server.ts. Passes description, schema (updateProjectSchema), and handler (updateProject).
registerTool( server, "update_project", "Update fields on an existing project. Only the fields you provide are changed. Use status='CLOSED' to close a project. CLOSED projects remain fully editable — Capsule does not enforce closed-record immutability. Stage moves and description edits on a CLOSED project are accepted without warning.", updateProjectSchema, updateProject, ); - src/server.ts:65-69 (registration)Import of `updateProjectSchema` and `updateProject` from ./tools/projects.js into src/server.ts, used for the tool registration.
updateProjectSchema, updateProject, deleteProjectSchema, deleteProject, } from "./tools/projects.js"; - src/server/register-tool.ts:39-59 (helper)The `registerTool` helper function that wraps the MCP SDK's server.registerTool to reduce boilerplate. It takes name, description, schema, and handler, and wraps the return value in the standard MCP text-content response.
export function registerTool<Schema extends z.ZodObject<ZodRawShape>>( server: McpServer, name: string, description: string, schema: Schema, handler: (input: z.infer<Schema>) => Promise<unknown>, ): void { // Use the SDK config-form registerTool with the full Zod schema. The // deprecated shape overload rebuilds z.object(schema.shape), which drops // object-level refinements such as superRefine. const registerWithSchema = server.registerTool.bind(server) as ( toolName: string, config: { description: string; inputSchema: Schema }, callback: (input: z.infer<Schema>) => Promise<CallToolResult>, ) => void; registerWithSchema(name, { description, inputSchema: schema }, async (input) => { const result = await handler(input); return wrapAsText(result); }); }