@jam-mcp/server 1.0.1 → 1.2.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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +86 -72
  3. package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
  4. package/dist/adapters/credentials/windows-user-env.js +20 -1
  5. package/dist/adapters/jira-cloud/jira-client.d.ts +10 -1
  6. package/dist/adapters/jira-cloud/jira-client.js +1 -1
  7. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
  8. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
  9. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
  10. package/dist/adapters/jira-cloud/jira-read.adapter.js +17 -0
  11. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +41 -6
  12. package/dist/adapters/jira-cloud/jira-write.adapter.js +96 -11
  13. package/dist/application/apply-create-issue.d.ts +20 -0
  14. package/dist/application/apply-create-issue.js +187 -0
  15. package/dist/application/apply-write.d.ts +25 -0
  16. package/dist/application/apply-write.js +166 -0
  17. package/dist/application/plan-create-issue.d.ts +44 -0
  18. package/dist/application/plan-create-issue.js +188 -0
  19. package/dist/application/plan-write.d.ts +39 -0
  20. package/dist/application/plan-write.js +210 -0
  21. package/dist/application/write-plan-store.d.ts +42 -0
  22. package/dist/application/write-plan-store.js +81 -0
  23. package/dist/bootstrap/boot-health-gate.js +2 -2
  24. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  25. package/dist/cli/auth.d.ts +6 -0
  26. package/dist/cli/auth.js +2 -1
  27. package/dist/cli-entry.js +33 -33
  28. package/dist/deps.d.ts +26 -0
  29. package/dist/deps.js +14 -0
  30. package/dist/domain/adf.d.ts +35 -0
  31. package/dist/domain/adf.js +65 -0
  32. package/dist/domain/errors.d.ts +1 -1
  33. package/dist/domain/errors.js +22 -0
  34. package/dist/domain/write.d.ts +230 -0
  35. package/dist/domain/write.js +65 -0
  36. package/dist/mcp/create-server.d.ts +12 -3
  37. package/dist/mcp/create-server.js +35 -6
  38. package/dist/mcp/tools/jira-write-apply.tool.d.ts +3 -0
  39. package/dist/mcp/tools/jira-write-apply.tool.js +33 -0
  40. package/dist/mcp/tools/jira-write-plan.tool.d.ts +3 -0
  41. package/dist/mcp/tools/jira-write-plan.tool.js +85 -0
  42. package/dist/policy/consistency-policy.d.ts +10 -4
  43. package/dist/policy/create-policy.d.ts +86 -0
  44. package/dist/policy/create-policy.js +182 -0
  45. package/dist/policy/write-policy.d.ts +69 -0
  46. package/dist/policy/write-policy.js +128 -0
  47. package/dist/ports/jira-create-metadata.port.d.ts +25 -0
  48. package/dist/ports/jira-create-metadata.port.js +1 -0
  49. package/dist/ports/jira-read.port.d.ts +23 -0
  50. package/dist/ports/jira-write.port.d.ts +27 -4
  51. package/package.json +69 -69
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 colosair
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 colosair
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,72 +1,86 @@
1
- # @jam-mcp/server
2
-
3
- JAM (Jira Agent MCP) itself: the MCP server, the setup core, and the `jam` CLI.
4
-
5
- JAM is an agent-facing Jira **read** layer. It takes over the decisions an agent
6
- should not be making — which fields to request, when to paginate, when to read
7
- the comment thread, what to do when a result is too big — so everyday reads stay
8
- cheap and important judgements still get full context.
9
-
10
- ## The three tools
11
-
12
- The external contract is exactly three tools. Adding or renaming one is a
13
- breaking change.
14
-
15
- | Tool | Use it for |
16
- |---|---|
17
- | `jira_search` | listing, discovery, "what's open", picking candidates |
18
- | `jira_context` | readiness, blockers, dependencies, priority |
19
- | `jira_full` | agreement, contract, approval, closure |
20
-
21
- A `jira_search` result is never complete issue context nothing about
22
- agreement, approval, or done-ness follows from it.
23
-
24
- ## Evidence boundary
25
-
26
- Every result carries a `meta` block, and nothing is ever truncated silently.
27
-
28
- `meta.complete` means **JAM finished the Jira retrieval with no known loss**. It
29
- is not a statement about the project: not readiness, not "unblocked", not "the
30
- whole story". What JAM did not look at is named in the same block —
31
- `evidenceScope` and `limitations` call out the repository, external sources, and
32
- dependencies that live outside Jira. `provenance` and `source` say where the
33
- records came from.
34
-
35
- So an empty comment thread on a complete read is a complete read *of Jira*, not
36
- proof that nothing was agreed. If the issue points at an external canonical
37
- source, that source is what settles the question.
38
-
39
- ## Running it
40
-
41
- Don't install this package to use JAM. Your coding agent launches
42
- [`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
43
- reads your `~/.jam/config.yaml`, decides which JAM build this machine should
44
- run, and dispatches here. Naming the server directly instead pins one machine
45
- to one build and bypasses that choice and it is what keeps a committed
46
- `.mcp.json` free of machine-specific paths.
47
-
48
- The same binary carries the CLI, and every command below is reachable through
49
- the launcher:
50
-
51
- ```text
52
- serve Run the MCP server over stdio
53
- doctor Diagnose config, credentials and Jira connectivity
54
- setup Wire up JAM and verify (personal by default; --shared for the team)
55
- auth login Store Jira credentials in this user's OS secret store
56
- runtime Show or change which JAM build this machine runs
57
- ```
58
-
59
- Written out, that is `npx --yes @jam-mcp/launcher@1.0.1 doctor`, or just `jam
60
- doctor` if you took the launcher's optional global install. Starting from
61
- nothing — no install, no runtime chosen yet — use
62
- `npx --yes @jam-mcp/bootstrap@1.0.1 init` instead.
63
-
64
- Credentials come from the process environment or this user's OS secret store —
65
- never from a repository file — and never appear in logs, telemetry, or tool
66
- results. `stdout` is reserved for the MCP protocol and for JSON output;
67
- diagnostics go to `stderr`.
68
-
69
- ## More
70
-
71
- - [Repository README](https://github.com/colosair/jam#readme)
72
- - [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
1
+ # @jam-mcp/server
2
+
3
+ JAM (Jira Agent MCP) itself: the MCP server, the setup core, and the `jam` CLI.
4
+
5
+ JAM is an agent-facing Jira **read** layer. It takes over the decisions an agent
6
+ should not be making — which fields to request, when to paginate, when to read
7
+ the comment thread, what to do when a result is too big — so everyday reads stay
8
+ cheap and important judgements still get full context.
9
+
10
+ ## The tools
11
+
12
+ The external contract is five tools. Adding or renaming one is a breaking
13
+ change.
14
+
15
+ | Tool | Use it for |
16
+ |---|---|
17
+ | `jira_search` | listing, discovery, "what's open", picking candidates |
18
+ | `jira_context` | readiness, blockers, dependencies, priority |
19
+ | `jira_full` | agreement, contract, approval, closure |
20
+ | `jira_write_plan` | work out how to change an issue — changes nothing |
21
+ | `jira_write_apply` | apply a plan, then confirm it by reading the issue back |
22
+
23
+ A `jira_search` result is never complete issue context — nothing about
24
+ agreement, approval, or done-ness follows from it.
25
+
26
+ Writing is deliberately two calls. `jira_write_apply` takes a `planId` and no
27
+ payload, so a change cannot be made that JAM has not first read the issue for,
28
+ checked against the configured project, and described. Before writing it
29
+ re-reads the issue and refuses if it moved; after writing it reads again and
30
+ refuses to report success unless the intended result is actually there. An
31
+ ambiguous failure is reported as uncertain rather than retried — retrying a
32
+ write that may have landed is how one comment becomes two.
33
+
34
+ Three operations: `comment.add` (plain text, converted to ADF here),
35
+ `field.update` (summary, priority, labels, components), and `status.transition`
36
+ (matched against the transitions Jira currently offers, never a guessed id).
37
+
38
+ ## Evidence boundary
39
+
40
+ Every result carries a `meta` block, and nothing is ever truncated silently.
41
+
42
+ `meta.complete` means **JAM finished the Jira retrieval with no known loss**. It
43
+ is not a statement about the project: not readiness, not "unblocked", not "the
44
+ whole story". What JAM did not look at is named in the same block —
45
+ `evidenceScope` and `limitations` call out the repository, external sources, and
46
+ dependencies that live outside Jira. `provenance` and `source` say where the
47
+ records came from.
48
+
49
+ So an empty comment thread on a complete read is a complete read *of Jira*, not
50
+ proof that nothing was agreed. If the issue points at an external canonical
51
+ source, that source is what settles the question.
52
+
53
+ ## Running it
54
+
55
+ Don't install this package to use JAM. Your coding agent launches
56
+ [`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
57
+ reads your `~/.jam/config.yaml`, decides which JAM build this machine should
58
+ run, and dispatches here. Naming the server directly instead pins one machine
59
+ to one build and bypasses that choice and it is what keeps a committed
60
+ `.mcp.json` free of machine-specific paths.
61
+
62
+ The same binary carries the CLI, and every command below is reachable through
63
+ the launcher:
64
+
65
+ ```text
66
+ serve Run the MCP server over stdio
67
+ doctor Diagnose config, credentials and Jira connectivity
68
+ setup Wire up JAM and verify (personal by default; --shared for the team)
69
+ auth login Store Jira credentials in this user's OS secret store
70
+ runtime Show or change which JAM build this machine runs
71
+ ```
72
+
73
+ Written out, that is `npx --yes @jam-mcp/launcher@1.2.0 doctor`, or just `jam
74
+ doctor` if you took the launcher's optional global install. Starting from
75
+ nothing — no install, no runtime chosen yet — use
76
+ `npx --yes @jam-mcp/bootstrap@1.2.0 init` instead.
77
+
78
+ Credentials come from the process environment or this user's OS secret store —
79
+ never from a repository file — and never appear in logs, telemetry, or tool
80
+ results. `stdout` is reserved for the MCP protocol and for JSON output;
81
+ diagnostics go to `stderr`.
82
+
83
+ ## More
84
+
85
+ - [Repository README](https://github.com/colosair/jam#readme)
86
+ - [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
@@ -15,6 +15,8 @@ export type RegQueryFn = (name: string) => string | undefined;
15
15
  */
16
16
  export declare class WindowsUserEnvCredentialSource implements CredentialValueSource {
17
17
  private readonly queryFn;
18
- constructor(queryFn?: RegQueryFn);
18
+ private readonly env;
19
+ constructor(queryFn?: RegQueryFn, env?: NodeJS.ProcessEnv);
19
20
  read(): RawCredentialValues;
20
21
  }
22
+ export declare function userEnvDisabled(env?: NodeJS.ProcessEnv): boolean;
@@ -15,12 +15,16 @@ import { CREDENTIAL_ENV_KEYS } from "./process-env.js";
15
15
  */
16
16
  export class WindowsUserEnvCredentialSource {
17
17
  queryFn;
18
- constructor(queryFn = defaultRegQuery) {
18
+ env;
19
+ constructor(queryFn = defaultRegQuery, env = process.env) {
19
20
  this.queryFn = queryFn;
21
+ this.env = env;
20
22
  }
21
23
  read() {
22
24
  if (process.platform !== "win32")
23
25
  return {};
26
+ if (userEnvDisabled(this.env))
27
+ return {};
24
28
  const out = {};
25
29
  for (const key of CREDENTIAL_ENV_KEYS) {
26
30
  const value = this.queryFn(key)?.trim();
@@ -30,6 +34,21 @@ export class WindowsUserEnvCredentialSource {
30
34
  return out;
31
35
  }
32
36
  }
37
+ /**
38
+ * Escape hatch for isolated test sandboxes, matching JAM_DISABLE_SECRET_STORE.
39
+ *
40
+ * HKCU\Environment is per-user, not per-HOME, so repointing HOME does not make
41
+ * a sandbox credential-free on Windows: a developer who ran `setx JIRA_API_TOKEN`
42
+ * once has credentials that every process of theirs can see. Without this, a
43
+ * hermetic test would pass or fail depending on whose machine ran it, and
44
+ * "zero HOME" would be mistaken for "zero credentials".
45
+ *
46
+ * Not a user-facing feature. Production never sets it.
47
+ */
48
+ const DISABLE_ENV = "JAM_DISABLE_USER_ENV";
49
+ export function userEnvDisabled(env = process.env) {
50
+ return Boolean(env[DISABLE_ENV]);
51
+ }
33
52
  const VALUE_LINE = /^\s*\S+\s+REG_(?:SZ|EXPAND_SZ)\s+(.*)$/;
34
53
  // Built from a char code rather than a literal backslash escape - a lone
35
54
  // backslash before a letter isn't a recognized JS escape and silently
@@ -2,9 +2,18 @@ import { JamError } from "../../domain/errors.js";
2
2
  import type { CredentialPort } from "../../ports/credentials.port.js";
3
3
  export type JiraRequest = {
4
4
  path: string;
5
- method?: "GET" | "POST";
5
+ method?: "GET" | "POST" | "PUT";
6
6
  query?: Record<string, string | number | undefined>;
7
7
  body?: unknown;
8
+ /**
9
+ * Whether a transient failure may be retried.
10
+ *
11
+ * Reads say yes and get the retry loop below. Writes say no: a request that
12
+ * timed out may already have been applied, so resending it is how one
13
+ * comment becomes two. The write path resolves that ambiguity by reading the
14
+ * issue back, never by trying again.
15
+ */
16
+ retry?: boolean;
8
17
  };
9
18
  export type JiraResponse<T> = {
10
19
  data: T;
@@ -67,7 +67,7 @@ export class JiraClient {
67
67
  // ponytail: fixed 2 retries on transient statuses; add backoff tuning if
68
68
  // `complete` searches start tripping Jira's rate limiter in practice.
69
69
  const transient = res.status === 429 || res.status >= 500;
70
- if (transient && attempt < MAX_RETRIES) {
70
+ if (req.retry !== false && transient && attempt < MAX_RETRIES) {
71
71
  lastError = error;
72
72
  await sleep(retryDelayMs(res.headers.get("retry-after"), attempt));
73
73
  continue;
@@ -0,0 +1,27 @@
1
+ import type { CreateFieldMetadata, CreateIssueType } from "../../domain/write.js";
2
+ import type { CredentialPort } from "../../ports/credentials.port.js";
3
+ import type { JiraCreateMetadataPort } from "../../ports/jira-create-metadata.port.js";
4
+ /**
5
+ * Jira Cloud REST v3 create metadata, per project and per issue type.
6
+ *
7
+ * The two-endpoint form, not the aggregate `createmeta?expand=` one: the
8
+ * aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
9
+ * every field in one document, which is both larger and less precise than the
10
+ * question being asked. Planning wants one project's issue types, and then one
11
+ * issue type's fields.
12
+ *
13
+ * `retry: false` throughout. These answers decide whether a create is possible
14
+ * and what it will contain, so a retried-and-stale answer is worse than a
15
+ * failure - the same reason `getTransitions` does not retry.
16
+ *
17
+ * Both mappers are defensive about shape. Jira omits fields it considers
18
+ * irrelevant and different deployments populate different ones, so anything
19
+ * unrecognised is dropped rather than guessed at: an entry JAM cannot read is
20
+ * an entry JAM must not claim to have understood.
21
+ */
22
+ export declare class JiraCloudCreateMetadataAdapter implements JiraCreateMetadataPort {
23
+ private readonly client;
24
+ constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
25
+ getIssueTypes(projectKey: string): Promise<CreateIssueType[]>;
26
+ getCreateFields(projectKey: string, issueTypeId: string): Promise<CreateFieldMetadata[]>;
27
+ }
@@ -0,0 +1,81 @@
1
+ import { JiraClient } from "./jira-client.js";
2
+ /**
3
+ * Jira Cloud REST v3 create metadata, per project and per issue type.
4
+ *
5
+ * The two-endpoint form, not the aggregate `createmeta?expand=` one: the
6
+ * aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
7
+ * every field in one document, which is both larger and less precise than the
8
+ * question being asked. Planning wants one project's issue types, and then one
9
+ * issue type's fields.
10
+ *
11
+ * `retry: false` throughout. These answers decide whether a create is possible
12
+ * and what it will contain, so a retried-and-stale answer is worse than a
13
+ * failure - the same reason `getTransitions` does not retry.
14
+ *
15
+ * Both mappers are defensive about shape. Jira omits fields it considers
16
+ * irrelevant and different deployments populate different ones, so anything
17
+ * unrecognised is dropped rather than guessed at: an entry JAM cannot read is
18
+ * an entry JAM must not claim to have understood.
19
+ */
20
+ export class JiraCloudCreateMetadataAdapter {
21
+ client;
22
+ constructor(credentials, fetchImpl) {
23
+ this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
24
+ }
25
+ async getIssueTypes(projectKey) {
26
+ const { data } = await this.client.request({
27
+ path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`,
28
+ retry: false,
29
+ });
30
+ return (data.issueTypes ?? [])
31
+ .filter((t) => typeof t.id === "string" && typeof t.name === "string")
32
+ .map((t) => ({ id: t.id, name: t.name, subtask: t.subtask === true }));
33
+ }
34
+ async getCreateFields(projectKey, issueTypeId) {
35
+ const { data } = await this.client.request({
36
+ path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`,
37
+ retry: false,
38
+ });
39
+ return (data.fields ?? [])
40
+ .map(toFieldMetadata)
41
+ .filter((f) => f !== undefined);
42
+ }
43
+ }
44
+ function toFieldMetadata(raw) {
45
+ // Jira has called this `fieldId` and `key` in different responses. Without
46
+ // one of them the entry cannot be matched to anything, so it is dropped -
47
+ // and if it was required, the required-field gate will refuse the plan
48
+ // because JAM cannot show it was supplied.
49
+ const id = typeof raw.fieldId === "string" ? raw.fieldId : typeof raw.key === "string" ? raw.key : undefined;
50
+ if (!id)
51
+ return undefined;
52
+ const allowed = mapAllowedValues(raw.allowedValues);
53
+ return {
54
+ id,
55
+ name: typeof raw.name === "string" ? raw.name : id,
56
+ required: raw.required === true,
57
+ hasDefaultValue: raw.hasDefaultValue === true,
58
+ ...(allowed ? { allowedValues: allowed } : {}),
59
+ };
60
+ }
61
+ /**
62
+ * Allowed values, when Jira constrains the field at all.
63
+ *
64
+ * Undefined and empty mean different things and are kept apart: undefined is
65
+ * "Jira did not constrain this", empty is "Jira constrains it and offers
66
+ * nothing". The first permits a free value, the second permits none.
67
+ */
68
+ function mapAllowedValues(raw) {
69
+ if (!Array.isArray(raw))
70
+ return undefined;
71
+ return raw.map((entry) => {
72
+ const id = typeof entry?.id === "string" ? entry.id : undefined;
73
+ // Components and priorities use `name`; some option fields use `value`.
74
+ const name = typeof entry?.name === "string"
75
+ ? entry.name
76
+ : typeof entry?.value === "string"
77
+ ? entry.value
78
+ : undefined;
79
+ return { ...(id ? { id } : {}), ...(name ? { name } : {}) };
80
+ });
81
+ }
@@ -1,11 +1,20 @@
1
1
  import type { ProjectConfig } from "../../config/schema.js";
2
2
  import type { CredentialPort } from "../../ports/credentials.port.js";
3
- import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
3
+ import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssueRequest, GetIssueResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
4
4
  export declare class JiraCloudReadAdapter implements JiraReadPort {
5
5
  private readonly config;
6
6
  private readonly client;
7
7
  constructor(credentials: CredentialPort, config: ProjectConfig, fetchImpl?: typeof fetch);
8
8
  searchPage(req: SearchPageRequest): Promise<SearchPageResult>;
9
+ /**
10
+ * `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
11
+ *
12
+ * This is what ConsistencyPolicy means by a direct issue read, and the write
13
+ * plane is the only caller. A 404 is an answer, not a failure: the issue is
14
+ * not there, or not visible to this account, and the caller decides which of
15
+ * those matters.
16
+ */
17
+ getIssue(req: GetIssueRequest): Promise<GetIssueResult>;
9
18
  getIssues(req: GetIssuesRequest): Promise<GetIssuesResult>;
10
19
  getComments(req: GetCommentsRequest): Promise<GetCommentsResult>;
11
20
  listProjects(): Promise<ListProjectsResult>;
@@ -27,6 +27,23 @@ export class JiraCloudReadAdapter {
27
27
  result.nextPageToken = data.nextPageToken;
28
28
  return result;
29
29
  }
30
+ /**
31
+ * `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
32
+ *
33
+ * This is what ConsistencyPolicy means by a direct issue read, and the write
34
+ * plane is the only caller. A 404 is an answer, not a failure: the issue is
35
+ * not there, or not visible to this account, and the caller decides which of
36
+ * those matters.
37
+ */
38
+ async getIssue(req) {
39
+ const { data, bytes } = await this.client.request({
40
+ path: `rest/api/3/issue/${encodeURIComponent(req.key)}`,
41
+ query: { fields: req.fields.join(",") },
42
+ });
43
+ if (!data?.key)
44
+ return { responseBytes: bytes };
45
+ return { issue: mapIssueWithMeta(data, this.config).issue, responseBytes: bytes };
46
+ }
30
47
  async getIssues(req) {
31
48
  const issues = [];
32
49
  const commentTotals = {};
@@ -1,12 +1,47 @@
1
+ import type { JiraTransition } from "../../domain/write.js";
2
+ import type { CredentialPort } from "../../ports/credentials.port.js";
1
3
  import type { JiraWritePort } from "../../ports/jira-write.port.js";
2
4
  /**
3
- * Deliberate stub. The first release is read-only; the existing Atlassian MCP
4
- * remains the write path. The port exists so adding writes later is an adapter
5
- * swap rather than an application-layer change.
5
+ * Jira Cloud REST v3, mutating half.
6
+ *
7
+ * Every request here sets `retry: false`. The read adapter's retry loop is
8
+ * correct for reads and dangerous here: a request that times out after Jira
9
+ * accepted it is indistinguishable from one Jira never saw, and resending it
10
+ * duplicates the change. The application layer resolves that by reading the
11
+ * issue back - see applyWritePlan.
12
+ *
13
+ * This adapter also never reads its own work. Confirmation is a direct issue
14
+ * GET through the read port, which keeps "what JAM did" and "what Jira shows"
15
+ * two separate observations rather than one hopeful one.
6
16
  */
7
- export declare class UnsupportedJiraWriteAdapter implements JiraWritePort {
8
- updateIssue(): Promise<void>;
9
- addComment(): Promise<{
17
+ export declare class JiraCloudWriteAdapter implements JiraWritePort {
18
+ private readonly client;
19
+ constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
20
+ /**
21
+ * Create one issue, once.
22
+ *
23
+ * `retry: false` matters more here than anywhere else behind this port. A
24
+ * retried update converges; a retried create leaves two issues, and the
25
+ * second one has a different key nobody is holding. An ambiguous failure is
26
+ * handed to the application layer as JAM_WRITE_UNCERTAIN and resolved by
27
+ * looking, never by sending it again.
28
+ */
29
+ createIssue(fields: Record<string, unknown>): Promise<{
10
30
  id: string;
31
+ key: string;
11
32
  }>;
33
+ updateIssue(key: string, fields: Record<string, unknown>): Promise<void>;
34
+ /**
35
+ * Add a comment, converting plain text to ADF here rather than accepting ADF.
36
+ *
37
+ * Jira's comment body is a document tree, and letting an agent supply one
38
+ * would mean accepting arbitrary structure - panels, mentions, embedded
39
+ * content - through a field that reads like "text". The conversion is narrow
40
+ * on purpose: paragraphs, and nothing else.
41
+ */
42
+ addComment(key: string, body: string): Promise<{
43
+ id: string;
44
+ }>;
45
+ getTransitions(key: string): Promise<JiraTransition[]>;
46
+ transitionIssue(key: string, transitionId: string): Promise<void>;
12
47
  }
@@ -1,17 +1,102 @@
1
+ import { textToAdf } from "../../domain/adf.js";
1
2
  import { JamError } from "../../domain/errors.js";
3
+ import { JiraClient } from "./jira-client.js";
2
4
  /**
3
- * Deliberate stub. The first release is read-only; the existing Atlassian MCP
4
- * remains the write path. The port exists so adding writes later is an adapter
5
- * swap rather than an application-layer change.
5
+ * Jira Cloud REST v3, mutating half.
6
+ *
7
+ * Every request here sets `retry: false`. The read adapter's retry loop is
8
+ * correct for reads and dangerous here: a request that times out after Jira
9
+ * accepted it is indistinguishable from one Jira never saw, and resending it
10
+ * duplicates the change. The application layer resolves that by reading the
11
+ * issue back - see applyWritePlan.
12
+ *
13
+ * This adapter also never reads its own work. Confirmation is a direct issue
14
+ * GET through the read port, which keeps "what JAM did" and "what Jira shows"
15
+ * two separate observations rather than one hopeful one.
6
16
  */
7
- export class UnsupportedJiraWriteAdapter {
8
- async updateIssue() {
9
- throw notSupported();
17
+ export class JiraCloudWriteAdapter {
18
+ client;
19
+ constructor(credentials, fetchImpl) {
20
+ this.client = fetchImpl
21
+ ? new JiraClient(credentials, fetchImpl)
22
+ : new JiraClient(credentials);
10
23
  }
11
- async addComment() {
12
- throw notSupported();
24
+ /**
25
+ * Create one issue, once.
26
+ *
27
+ * `retry: false` matters more here than anywhere else behind this port. A
28
+ * retried update converges; a retried create leaves two issues, and the
29
+ * second one has a different key nobody is holding. An ambiguous failure is
30
+ * handed to the application layer as JAM_WRITE_UNCERTAIN and resolved by
31
+ * looking, never by sending it again.
32
+ */
33
+ async createIssue(fields) {
34
+ const { data } = await this.client.request({
35
+ path: "rest/api/3/issue",
36
+ method: "POST",
37
+ body: { fields },
38
+ retry: false,
39
+ });
40
+ if (!data?.key || !data.id) {
41
+ // Jira took the request and told us nothing identifying, so an issue may
42
+ // now exist that JAM cannot name. That is exactly the uncertain case:
43
+ // report it, do not retry, and let a person look.
44
+ throw new JamError("JAM_WRITE_UNCERTAIN", "Jira accepted a create but returned no issue key, so JAM cannot tell which issue it made - or whether it made one. Look in the project before trying again: retrying could create a second issue.", { project: fields["project"]?.key });
45
+ }
46
+ return { id: data.id, key: data.key };
47
+ }
48
+ async updateIssue(key, fields) {
49
+ await this.client.request({
50
+ path: `rest/api/3/issue/${encodeURIComponent(key)}`,
51
+ method: "PUT",
52
+ body: { fields },
53
+ retry: false,
54
+ });
55
+ }
56
+ /**
57
+ * Add a comment, converting plain text to ADF here rather than accepting ADF.
58
+ *
59
+ * Jira's comment body is a document tree, and letting an agent supply one
60
+ * would mean accepting arbitrary structure - panels, mentions, embedded
61
+ * content - through a field that reads like "text". The conversion is narrow
62
+ * on purpose: paragraphs, and nothing else.
63
+ */
64
+ async addComment(key, body) {
65
+ const { data } = await this.client.request({
66
+ path: `rest/api/3/issue/${encodeURIComponent(key)}/comment`,
67
+ method: "POST",
68
+ body: { body: textToAdf(body) },
69
+ retry: false,
70
+ });
71
+ if (!data?.id) {
72
+ // Jira accepted it but told us nothing identifying. Treat that as
73
+ // unconfirmed rather than inventing an id: the caller's direct read is
74
+ // what decides whether the comment exists.
75
+ throw new JamError("JAM_WRITE_UNCERTAIN", `Jira accepted a comment on ${key} but returned no comment id, so JAM cannot confirm which comment it created.`, { issueKey: key });
76
+ }
77
+ return { id: data.id };
78
+ }
79
+ async getTransitions(key) {
80
+ const { data } = await this.client.request({
81
+ path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
82
+ // A read, but on the write path: its answer decides a mutation, so a
83
+ // retried-and-stale transition list would be worse than a failure.
84
+ retry: false,
85
+ });
86
+ return (data.transitions ?? [])
87
+ .filter((t) => typeof t.id === "string")
88
+ .map((t) => ({
89
+ id: t.id,
90
+ name: t.name ?? t.id,
91
+ to: t.to?.name ?? t.name ?? "",
92
+ }));
93
+ }
94
+ async transitionIssue(key, transitionId) {
95
+ await this.client.request({
96
+ path: `rest/api/3/issue/${encodeURIComponent(key)}/transitions`,
97
+ method: "POST",
98
+ body: { transition: { id: transitionId } },
99
+ retry: false,
100
+ });
13
101
  }
14
- }
15
- function notSupported() {
16
- return new JamError("CONFIG_INVALID", "JAM is read-only in this release. Use the Atlassian MCP for Jira writes.");
17
102
  }
@@ -0,0 +1,20 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import type { CreateIssueWritePlan, WriteApplyReceipt } from "../domain/write.js";
3
+ /**
4
+ * Create the issue a plan describes, then go and look at what was created.
5
+ *
6
+ * The same three-step shape as every other apply, with one substitution.
7
+ * Updating an existing issue re-reads that issue and compares its revision;
8
+ * there is no issue to re-read here, so what gets checked instead is the
9
+ * premise the plan was built on - the project's create schema. That is
10
+ * creation's concurrency boundary.
11
+ *
12
+ * 1. Re-derive the schema and check the plan's premises still hold. If the
13
+ * issue type went away, or a required field JAM cannot fill appeared, or a
14
+ * resolved value is no longer offered, nothing is sent.
15
+ * 2. POST the create exactly once. No retry, ever - see below.
16
+ * 3. Read the new issue by the key Jira returned, and check it says what the
17
+ * plan intended. A 201 with a key is Jira accepting a request, not
18
+ * evidence that the issue exists as described.
19
+ */
20
+ export declare function applyCreateIssue(deps: JamDeps, plan: CreateIssueWritePlan): Promise<WriteApplyReceipt>;