@jam-mcp/server 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ import { JamError } from "../domain/errors.js";
2
+ import { assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
3
+ /**
4
+ * Work out whether a requested change is currently possible, and describe it.
5
+ *
6
+ * Reads only. Nothing here mutates Jira, and that is the whole point of the
7
+ * step: the agent gets to see what the issue looks like now, what JAM would
8
+ * do to it, and what a direct read will have to show before JAM will call it
9
+ * done - all before anything has happened.
10
+ *
11
+ * The order matters. Scope and operation are checked before any Jira call, so
12
+ * an out-of-scope key costs nothing and comes back as a JAM refusal rather
13
+ * than a 404. Everything after that is derived from the issue as Jira reports
14
+ * it right now, never from what the caller asserted about it.
15
+ */
16
+ export async function planWrite(deps, request) {
17
+ const issueKey = request.key.trim().toUpperCase();
18
+ const projectKey = assertWriteScope(issueKey, deps.config.project.key);
19
+ const operation = assertOperationAllowed(request.operation);
20
+ // Everything that can be refused from the request alone is refused here,
21
+ // before a Jira call is spent on it. An agent asking to write a field JAM
22
+ // does not write should get that answer, not a round trip and then that
23
+ // answer.
24
+ const input = validateInput(operation, request.input);
25
+ const issue = await readIssue(deps, issueKey);
26
+ const { before, intendedAfter, mutation, transition } = await describe(deps, operation, issueKey, issue, input);
27
+ const createdAt = new Date();
28
+ const plan = deps.writePlans.create({
29
+ issueKey,
30
+ projectKey,
31
+ operation,
32
+ before,
33
+ intendedAfter,
34
+ baseUpdated: issue.updated,
35
+ createdAt: createdAt.toISOString(),
36
+ expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
37
+ ...(transition ? { transition } : {}),
38
+ mutation,
39
+ });
40
+ return {
41
+ plan,
42
+ receipt: {
43
+ status: "planned",
44
+ planId: plan.planId,
45
+ issue: plan.issueKey,
46
+ operation: plan.operation,
47
+ before: plan.before,
48
+ intendedAfter: plan.intendedAfter,
49
+ expiresAt: plan.expiresAt,
50
+ verification: { method: "direct-issue-read", expects: plan.intendedAfter },
51
+ },
52
+ };
53
+ }
54
+ /**
55
+ * The issue as Jira has it, read directly by key.
56
+ *
57
+ * A direct read, never a search: ConsistencyPolicy requires it for anything
58
+ * that decides a write, and a JQL result can lag behind the issue it describes.
59
+ */
60
+ export async function readIssue(deps, issueKey) {
61
+ const { issues } = await deps.jira.getIssues({
62
+ keys: [issueKey],
63
+ fields: ["summary", "status", "priority", "labels", "components", "updated"],
64
+ });
65
+ const issue = issues[0];
66
+ if (!issue) {
67
+ throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
68
+ }
69
+ return issue;
70
+ }
71
+ /**
72
+ * Check the request against the contract, and normalize it.
73
+ *
74
+ * Pure: no Jira, no state. Whether an operation is supported, whether a field
75
+ * is writable and whether the input is even the right shape are all knowable
76
+ * without asking Jira anything, so they are answered first.
77
+ */
78
+ function validateInput(operation, raw) {
79
+ switch (operation) {
80
+ case "comment.add": {
81
+ const text = raw.text;
82
+ if (typeof text !== "string" || text.trim().length === 0) {
83
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "comment.add needs non-empty `input.text`.", { operation });
84
+ }
85
+ return { text: text.trim() };
86
+ }
87
+ case "field.update":
88
+ return assertFieldsAllowed(raw);
89
+ case "status.transition": {
90
+ const status = raw.status;
91
+ if (typeof status !== "string" || status.trim().length === 0) {
92
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "status.transition needs non-empty `input.status`.", { operation });
93
+ }
94
+ return { status: status.trim() };
95
+ }
96
+ }
97
+ }
98
+ async function describe(deps, operation, issueKey, issue, input) {
99
+ switch (operation) {
100
+ case "comment.add": {
101
+ const { text } = input;
102
+ // Comments accumulate rather than replace, so `before` says what is
103
+ // there now by count - the plan is not claiming to know the thread.
104
+ return {
105
+ before: { comments: issue.comments.length },
106
+ intendedAfter: { commentAdded: text },
107
+ mutation: { kind: "comment", text },
108
+ };
109
+ }
110
+ case "field.update": {
111
+ const fields = input;
112
+ const before = {};
113
+ const after = {};
114
+ for (const [field, value] of Object.entries(fields)) {
115
+ before[field] = currentValue(issue, field);
116
+ after[field] = value;
117
+ }
118
+ return {
119
+ before,
120
+ intendedAfter: after,
121
+ mutation: { kind: "fields", fields: toJiraFields(fields) },
122
+ };
123
+ }
124
+ case "status.transition": {
125
+ const { status: target } = input;
126
+ // Ask Jira what is reachable rather than deriving an id from a name:
127
+ // transition ids are per-workflow, and a guessed one either fails or
128
+ // moves the issue somewhere nobody asked for.
129
+ const available = await deps.jiraWrite.getTransitions(issueKey);
130
+ const transition = resolveTransition(target, available);
131
+ return {
132
+ before: { status: issue.status },
133
+ intendedAfter: { status: transition.to },
134
+ mutation: { kind: "transition", transitionId: transition.id },
135
+ transition,
136
+ };
137
+ }
138
+ }
139
+ }
140
+ function currentValue(issue, field) {
141
+ switch (field) {
142
+ case "summary":
143
+ return issue.summary;
144
+ case "priority":
145
+ return issue.priority;
146
+ case "labels":
147
+ return issue.labels;
148
+ case "components":
149
+ return issue.components;
150
+ default:
151
+ return undefined;
152
+ }
153
+ }
154
+ /** Whitelisted values to the shapes Jira's field API expects. */
155
+ function toJiraFields(input) {
156
+ const fields = {};
157
+ if (input.summary !== undefined)
158
+ fields["summary"] = input.summary;
159
+ if (input.priority !== undefined)
160
+ fields["priority"] = { name: input.priority };
161
+ if (input.labels !== undefined)
162
+ fields["labels"] = input.labels;
163
+ if (input.components !== undefined) {
164
+ fields["components"] = input.components.map((name) => ({ name }));
165
+ }
166
+ return fields;
167
+ }
@@ -0,0 +1,42 @@
1
+ import type { WritePlan } from "../domain/write.js";
2
+ /**
3
+ * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
4
+ *
5
+ * In this process, and nowhere else. The alternative considered was a signed
6
+ * self-contained token, and it is worse here on both counts that matter: the
7
+ * signing key would have to come from somewhere (a new secret on disk, or a
8
+ * per-process key that gives the token exactly this lifetime anyway), and the
9
+ * mutation would have to travel through the agent to come back. Keeping the
10
+ * mutation in memory makes forgery impossible rather than merely hard - a
11
+ * `planId` is an opaque handle, and what it names never leaves this process.
12
+ *
13
+ * The cost is that plans do not survive a restart. That is acceptable: a plan
14
+ * is only valid while the issue has not moved, so a stale one was going to be
15
+ * rejected on its own terms, and re-planning is a single read.
16
+ *
17
+ * See docs/decisions/adr-jira-write-plane.md.
18
+ */
19
+ export declare class WritePlanStore {
20
+ private readonly now;
21
+ private readonly plans;
22
+ /** Injected by tests so expiry does not depend on wall-clock timing. */
23
+ constructor(now?: () => Date);
24
+ create(plan: Omit<WritePlan, "planId">): WritePlan;
25
+ /**
26
+ * Resolve a plan for applying.
27
+ *
28
+ * An expired plan is reported as expired rather than as missing: those are
29
+ * different situations, and telling them apart is the difference between
30
+ * "re-plan" and "you are calling this wrong".
31
+ */
32
+ take(planId: string): WritePlan;
33
+ /**
34
+ * Retire a plan once it has been applied.
35
+ *
36
+ * Single use, so a receipt cannot be turned into a second mutation by
37
+ * calling apply again with the same id - which for `comment.add` would mean
38
+ * two comments.
39
+ */
40
+ consume(planId: string): void;
41
+ private evictExpired;
42
+ }
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { JamError } from "../domain/errors.js";
3
+ import { planExpired } from "../policy/write-policy.js";
4
+ /**
5
+ * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
6
+ *
7
+ * In this process, and nowhere else. The alternative considered was a signed
8
+ * self-contained token, and it is worse here on both counts that matter: the
9
+ * signing key would have to come from somewhere (a new secret on disk, or a
10
+ * per-process key that gives the token exactly this lifetime anyway), and the
11
+ * mutation would have to travel through the agent to come back. Keeping the
12
+ * mutation in memory makes forgery impossible rather than merely hard - a
13
+ * `planId` is an opaque handle, and what it names never leaves this process.
14
+ *
15
+ * The cost is that plans do not survive a restart. That is acceptable: a plan
16
+ * is only valid while the issue has not moved, so a stale one was going to be
17
+ * rejected on its own terms, and re-planning is a single read.
18
+ *
19
+ * See docs/decisions/adr-jira-write-plane.md.
20
+ */
21
+ export class WritePlanStore {
22
+ now;
23
+ plans = new Map();
24
+ /** Injected by tests so expiry does not depend on wall-clock timing. */
25
+ constructor(now = () => new Date()) {
26
+ this.now = now;
27
+ }
28
+ create(plan) {
29
+ this.evictExpired();
30
+ const stored = { ...plan, planId: randomUUID() };
31
+ this.plans.set(stored.planId, stored);
32
+ return stored;
33
+ }
34
+ /**
35
+ * Resolve a plan for applying.
36
+ *
37
+ * An expired plan is reported as expired rather than as missing: those are
38
+ * different situations, and telling them apart is the difference between
39
+ * "re-plan" and "you are calling this wrong".
40
+ */
41
+ take(planId) {
42
+ const plan = this.plans.get(planId);
43
+ if (!plan) {
44
+ throw new JamError("JAM_WRITE_PLAN_NOT_FOUND", "No such write plan. Plans live in the running JAM server and do not survive a restart - call jira_write_plan again.", { planId });
45
+ }
46
+ if (planExpired(plan.expiresAt, this.now())) {
47
+ this.plans.delete(planId);
48
+ throw new JamError("JAM_WRITE_PLAN_EXPIRED", `This write plan expired at ${plan.expiresAt}. Re-plan against the current state of ${plan.issueKey}.`, { planId, issueKey: plan.issueKey, expiresAt: plan.expiresAt });
49
+ }
50
+ return plan;
51
+ }
52
+ /**
53
+ * Retire a plan once it has been applied.
54
+ *
55
+ * Single use, so a receipt cannot be turned into a second mutation by
56
+ * calling apply again with the same id - which for `comment.add` would mean
57
+ * two comments.
58
+ */
59
+ consume(planId) {
60
+ this.plans.delete(planId);
61
+ }
62
+ evictExpired() {
63
+ const now = this.now();
64
+ for (const [id, plan] of this.plans) {
65
+ if (planExpired(plan.expiresAt, now))
66
+ this.plans.delete(id);
67
+ }
68
+ }
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { toJamError } from "../domain/errors.js";
2
- import { createServer } from "../mcp/create-server.js";
2
+ import { createServer, TOOL_COUNT } from "../mcp/create-server.js";
3
3
  /**
4
4
  * One health-check core shared by `jam doctor`, `jam setup` and `jam serve`.
5
5
  *
@@ -60,7 +60,7 @@ export async function runHealthGate(deps, mode) {
60
60
  });
61
61
  try {
62
62
  createServer(deps);
63
- add({ name: "MCP server startup", ok: true, fatal: true, detail: "3 tools registered" });
63
+ add({ name: "MCP server startup", ok: true, fatal: true, detail: `${TOOL_COUNT} tools registered` });
64
64
  }
65
65
  catch (err) {
66
66
  add({ name: "MCP server startup", ok: false, fatal: true, detail: toJamError(err).message });
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
13
13
  export { LAUNCHER_PACKAGE_SPEC };
14
14
  export declare const JAM_MCP_ENTRY: {
15
15
  readonly command: "npx";
16
- readonly args: readonly ["--yes", "@jam-mcp/launcher@1.0.1", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.1.0", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded path to one
package/dist/cli-entry.js CHANGED
@@ -11,39 +11,39 @@ import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyComm
11
11
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
12
12
  * instead of reimplementing them.
13
13
  */
14
- export const USAGE = `jam - Jira Agent MCP
15
-
16
- Usage:
17
- jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
18
- jam doctor Diagnose config, credentials and Jira connectivity
19
- jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
20
- Wire up this project and run doctor. Binds it to you
21
- alone, writing nothing to the repository; --shared
22
- adopts JAM for the team (project.yaml, .mcp.json)
23
- jam runtime Show which JAM build this machine runs
24
- jam runtime use package | development <path>
25
- Change it (writes ~/.jam/config.yaml only, never a project)
26
- jam auth login Store Jira credentials in this user's OS secret store
27
- jam auth logout Remove them again
28
-
29
- For coding agents and scripts (stdout is JSON only, never prompts):
30
- jam setup --agent One shot: detect, plan, apply what is safe, verify
31
- jam setup plan --json Report what setup would change, changing nothing
32
- jam setup apply --non-interactive --json
33
- Execute the plan
34
- jam doctor --json Health check as structured output
35
- jam auth status --json Whether Jira credentials are configured (never their value)
36
-
37
- Environment:
38
- JIRA_BASE_URL https://your-site.atlassian.net
39
- JIRA_EMAIL Atlassian account email
40
- JIRA_API_TOKEN Atlassian API token
41
- JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
42
- .jira-agent/project.yaml exists yet
43
-
44
- Credentials and JAM_PROJECT_KEY are read from the current shell's environment
45
- first, then (on Windows) from the User environment - so a value set with
46
- \`setx\` works without opening a new terminal.
14
+ export const USAGE = `jam - Jira Agent MCP
15
+
16
+ Usage:
17
+ jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
18
+ jam doctor Diagnose config, credentials and Jira connectivity
19
+ jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
20
+ Wire up this project and run doctor. Binds it to you
21
+ alone, writing nothing to the repository; --shared
22
+ adopts JAM for the team (project.yaml, .mcp.json)
23
+ jam runtime Show which JAM build this machine runs
24
+ jam runtime use package | development <path>
25
+ Change it (writes ~/.jam/config.yaml only, never a project)
26
+ jam auth login Store Jira credentials in this user's OS secret store
27
+ jam auth logout Remove them again
28
+
29
+ For coding agents and scripts (stdout is JSON only, never prompts):
30
+ jam setup --agent One shot: detect, plan, apply what is safe, verify
31
+ jam setup plan --json Report what setup would change, changing nothing
32
+ jam setup apply --non-interactive --json
33
+ Execute the plan
34
+ jam doctor --json Health check as structured output
35
+ jam auth status --json Whether Jira credentials are configured (never their value)
36
+
37
+ Environment:
38
+ JIRA_BASE_URL https://your-site.atlassian.net
39
+ JIRA_EMAIL Atlassian account email
40
+ JIRA_API_TOKEN Atlassian API token
41
+ JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
42
+ .jira-agent/project.yaml exists yet
43
+
44
+ Credentials and JAM_PROJECT_KEY are read from the current shell's environment
45
+ first, then (on Windows) from the User environment - so a value set with
46
+ \`setx\` works without opening a new terminal.
47
47
  `;
48
48
  function findFlagValue(argv, flag) {
49
49
  const index = argv.indexOf(flag);
package/dist/deps.d.ts CHANGED
@@ -4,6 +4,8 @@ import type { ProjectConfig } from "./config/schema.js";
4
4
  import type { CachePort } from "./ports/cache.port.js";
5
5
  import type { CredentialPort } from "./ports/credentials.port.js";
6
6
  import type { JiraReadPort } from "./ports/jira-read.port.js";
7
+ import type { JiraWritePort } from "./ports/jira-write.port.js";
8
+ import { WritePlanStore } from "./application/write-plan-store.js";
7
9
  import type { TelemetryPort } from "./ports/telemetry.port.js";
8
10
  /** Everything the application layer is allowed to reach for. */
9
11
  export type JamDeps = {
@@ -12,6 +14,17 @@ export type JamDeps = {
12
14
  /** Where the project key came from when no config file supplied one. */
13
15
  keySource?: BootstrapSource;
14
16
  jira: JiraReadPort;
17
+ /**
18
+ * The mutating half. Separate from `jira` on purpose: reading and writing
19
+ * have different retry rules and different confirmation rules, and one port
20
+ * that did both would blur them.
21
+ */
22
+ jiraWrite: JiraWritePort;
23
+ /**
24
+ * Plans awaiting apply. Lives for the life of this server process - see
25
+ * WritePlanStore for why it is not persisted.
26
+ */
27
+ writePlans: WritePlanStore;
15
28
  cache: CachePort;
16
29
  telemetry: TelemetryPort;
17
30
  credentials: CredentialPort;
@@ -20,6 +33,8 @@ export type BuildDepsOptions = {
20
33
  cwd?: string;
21
34
  /** Injected by tests to bypass the real REST adapter. */
22
35
  jira?: JiraReadPort;
36
+ /** Injected by tests so no test can reach a real Jira write endpoint. */
37
+ jiraWrite?: JiraWritePort;
23
38
  /** Injected by tests to bypass the real process/registry credential lookup. */
24
39
  credentials?: CredentialPort;
25
40
  /**
package/dist/deps.js CHANGED
@@ -2,6 +2,7 @@ import { NoopCache } from "./adapters/cache/noop-cache.js";
2
2
  import { CompositeCredentialProvider } from "./adapters/credentials/composite.js";
3
3
  import { ConsoleTelemetry } from "./adapters/telemetry/console-telemetry.js";
4
4
  import { resolveProjectConfig } from "./bootstrap/project-config-resolver.js";
5
+ import { WritePlanStore } from "./application/write-plan-store.js";
5
6
  /**
6
7
  * Single composition root. `jam serve`, `jam doctor` and `jam setup` all wire
7
8
  * through here, so a doctor pass actually proves the server's configuration.
@@ -27,11 +28,18 @@ export async function buildDeps(options = {}) {
27
28
  const { JiraCloudReadAdapter } = await import("./adapters/jira-cloud/jira-read.adapter.js");
28
29
  jira = new JiraCloudReadAdapter(credentials, resolved.config);
29
30
  }
31
+ let jiraWrite = options.jiraWrite;
32
+ if (!jiraWrite) {
33
+ const { JiraCloudWriteAdapter } = await import("./adapters/jira-cloud/jira-write.adapter.js");
34
+ jiraWrite = new JiraCloudWriteAdapter(credentials);
35
+ }
30
36
  return {
31
37
  config: resolved.config,
32
38
  configPath: resolved.configPath,
33
39
  keySource: resolved.keySource,
34
40
  jira,
41
+ jiraWrite,
42
+ writePlans: new WritePlanStore(),
35
43
  cache: new NoopCache(),
36
44
  telemetry,
37
45
  credentials,
@@ -5,7 +5,7 @@
5
5
  * is mapped onto one of these codes so the agent (and `jam doctor`) can reason
6
6
  * about failures without parsing vendor-specific payloads.
7
7
  */
8
- export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE"];
8
+ export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
9
9
  export type JamErrorCode = (typeof JAM_ERROR_CODES)[number];
10
10
  export type JamErrorPayload = {
11
11
  error: {
@@ -17,6 +17,19 @@ export const JAM_ERROR_CODES = [
17
17
  "JIRA_UNAVAILABLE",
18
18
  "JAM_SETUP_REQUIRED",
19
19
  "JAM_BINDINGS_UNREADABLE",
20
+ // Write plane. Each names a distinct decision an agent has to make, which is
21
+ // why none of them collapse into CONFIG_INVALID: "you may not write there",
22
+ // "someone else moved it", and "we could not confirm it happened" call for
23
+ // three different next steps.
24
+ "JAM_WRITE_SCOPE_VIOLATION",
25
+ "JAM_WRITE_OPERATION_NOT_ALLOWED",
26
+ "JAM_WRITE_FIELD_NOT_ALLOWED",
27
+ "JAM_WRITE_TRANSITION_NOT_AVAILABLE",
28
+ "JAM_WRITE_PLAN_NOT_FOUND",
29
+ "JAM_WRITE_PLAN_EXPIRED",
30
+ "JAM_WRITE_CONFLICT",
31
+ "JAM_WRITE_VERIFICATION_FAILED",
32
+ "JAM_WRITE_UNCERTAIN",
20
33
  ];
21
34
  export class JamError extends Error {
22
35
  code;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The write contract.
3
+ *
4
+ * JAM's write plane is not a Jira REST proxy. An agent cannot hand JAM a
5
+ * mutation and have it forwarded; it describes an intent, JAM works out whether
6
+ * that intent is currently possible, and only a plan JAM itself produced can be
7
+ * applied. Everything in this file exists to keep that shape: a closed set of
8
+ * operations, a fixed field whitelist, and a plan that records what the issue
9
+ * looked like when the plan was made.
10
+ *
11
+ * Read semantics are deliberately not reused here. A read result's `meta`
12
+ * answers "how complete was this retrieval"; a write result answers "did this
13
+ * change happen, and did we see it happen". Mixing them would let a confident
14
+ * `complete: true` stand in for a verified mutation.
15
+ */
16
+ /** The operations the public MCP surface accepts. Nothing else is reachable. */
17
+ export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition"];
18
+ export type WriteOperation = (typeof WRITE_OPERATIONS)[number];
19
+ /**
20
+ * Fields `field.update` may touch.
21
+ *
22
+ * A whitelist rather than an open field map: an open map turns every
23
+ * project-specific screen, custom field and permission quirk into a runtime
24
+ * surprise, and makes "what can an agent change" unanswerable. Custom fields
25
+ * and assignee are deliberately absent - both need resolution work (schema
26
+ * discovery, accountId lookup) that belongs in its own round.
27
+ */
28
+ export declare const WRITABLE_FIELDS: readonly ["summary", "priority", "labels", "components"];
29
+ export type WritableField = (typeof WRITABLE_FIELDS)[number];
30
+ export type CommentAddInput = {
31
+ text: string;
32
+ };
33
+ export type FieldUpdateInput = {
34
+ summary?: string;
35
+ priority?: string;
36
+ labels?: string[];
37
+ components?: string[];
38
+ };
39
+ export type StatusTransitionInput = {
40
+ status: string;
41
+ };
42
+ export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput;
43
+ /** A transition as Jira currently offers it for one issue. */
44
+ export type JiraTransition = {
45
+ id: string;
46
+ name: string;
47
+ /** The status this transition leads to, as Jira names it. */
48
+ to: string;
49
+ };
50
+ /**
51
+ * What a plan captured, and what it intends.
52
+ *
53
+ * `baseUpdated` is the issue's `updated` timestamp at plan time. Apply re-reads
54
+ * the issue and refuses when it has moved: a plan that was valid is not the
55
+ * same as a plan that is still valid.
56
+ */
57
+ export type WritePlan = {
58
+ planId: string;
59
+ issueKey: string;
60
+ projectKey: string;
61
+ operation: WriteOperation;
62
+ /** Only the fields this operation touches. */
63
+ before: Record<string, unknown>;
64
+ intendedAfter: Record<string, unknown>;
65
+ baseUpdated: string;
66
+ createdAt: string;
67
+ expiresAt: string;
68
+ /**
69
+ * The transition Jira offered for this target status, resolved at plan time.
70
+ * Present only for `status.transition` - a transition id is never guessed
71
+ * from a status name.
72
+ */
73
+ transition?: JiraTransition;
74
+ /** Normalized payload the apply step will send. Never supplied by a caller. */
75
+ mutation: WriteMutation;
76
+ };
77
+ /** What apply will actually send. Produced by planning, never by an agent. */
78
+ export type WriteMutation = {
79
+ kind: "comment";
80
+ text: string;
81
+ } | {
82
+ kind: "fields";
83
+ fields: Record<string, unknown>;
84
+ } | {
85
+ kind: "transition";
86
+ transitionId: string;
87
+ };
88
+ /** What `jira_write_plan` returns. The mutation itself is not exposed. */
89
+ export type WritePlanReceipt = {
90
+ status: "planned";
91
+ planId: string;
92
+ issue: string;
93
+ operation: WriteOperation;
94
+ before: Record<string, unknown>;
95
+ intendedAfter: Record<string, unknown>;
96
+ expiresAt: string;
97
+ /** How the result of applying this plan will be confirmed. */
98
+ verification: {
99
+ method: "direct-issue-read";
100
+ /** What a direct read must show before JAM calls the write applied. */
101
+ expects: Record<string, unknown>;
102
+ };
103
+ };
104
+ /** What `jira_write_apply` returns once a direct read has confirmed the change. */
105
+ export type WriteApplyReceipt = {
106
+ status: "applied";
107
+ issue: string;
108
+ operation: WriteOperation;
109
+ before: Record<string, unknown>;
110
+ after: Record<string, unknown>;
111
+ /** Always true in an `applied` receipt - an unverified write is an error. */
112
+ verified: true;
113
+ /** Present for `comment.add`: the comment Jira created. */
114
+ commentId?: string;
115
+ };
116
+ export declare function isWriteOperation(value: string): value is WriteOperation;
117
+ export declare function isWritableField(value: string): value is WritableField;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The write contract.
3
+ *
4
+ * JAM's write plane is not a Jira REST proxy. An agent cannot hand JAM a
5
+ * mutation and have it forwarded; it describes an intent, JAM works out whether
6
+ * that intent is currently possible, and only a plan JAM itself produced can be
7
+ * applied. Everything in this file exists to keep that shape: a closed set of
8
+ * operations, a fixed field whitelist, and a plan that records what the issue
9
+ * looked like when the plan was made.
10
+ *
11
+ * Read semantics are deliberately not reused here. A read result's `meta`
12
+ * answers "how complete was this retrieval"; a write result answers "did this
13
+ * change happen, and did we see it happen". Mixing them would let a confident
14
+ * `complete: true` stand in for a verified mutation.
15
+ */
16
+ /** The operations the public MCP surface accepts. Nothing else is reachable. */
17
+ export const WRITE_OPERATIONS = ["comment.add", "field.update", "status.transition"];
18
+ /**
19
+ * Fields `field.update` may touch.
20
+ *
21
+ * A whitelist rather than an open field map: an open map turns every
22
+ * project-specific screen, custom field and permission quirk into a runtime
23
+ * surprise, and makes "what can an agent change" unanswerable. Custom fields
24
+ * and assignee are deliberately absent - both need resolution work (schema
25
+ * discovery, accountId lookup) that belongs in its own round.
26
+ */
27
+ export const WRITABLE_FIELDS = ["summary", "priority", "labels", "components"];
28
+ export function isWriteOperation(value) {
29
+ return WRITE_OPERATIONS.includes(value);
30
+ }
31
+ export function isWritableField(value) {
32
+ return WRITABLE_FIELDS.includes(value);
33
+ }
@@ -1,9 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { JamDeps } from "../deps.js";
3
3
  export declare const SERVER_NAME = "jam";
4
+ export declare const TOOL_COUNT: number;
4
5
  /**
5
- * The external contract: exactly three read tools, stable from the first
6
- * release. Internal changes (cache, Rovo, remote transport) must not add or
7
- * rename anything here.
6
+ * The external contract: three read tools and two write tools.
7
+ *
8
+ * The read three have been stable since the first release and do not change.
9
+ * The write pair is a single operation split in half on purpose - deciding and
10
+ * doing are separate calls, so an agent cannot mutate Jira without first
11
+ * having been shown what it is about to change. Internal changes (cache, Rovo,
12
+ * remote transport) must not add or rename anything here.
8
13
  */
9
14
  export declare function createServer(deps: JamDeps): McpServer;