@jam-mcp/server 1.0.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/adapters/cache/noop-cache.d.ts +7 -0
  4. package/dist/adapters/cache/noop-cache.js +12 -0
  5. package/dist/adapters/credentials/composite.d.ts +30 -0
  6. package/dist/adapters/credentials/composite.js +79 -0
  7. package/dist/adapters/credentials/process-env.d.ts +13 -0
  8. package/dist/adapters/credentials/process-env.js +17 -0
  9. package/dist/adapters/credentials/secret-store.d.ts +69 -0
  10. package/dist/adapters/credentials/secret-store.js +259 -0
  11. package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
  12. package/dist/adapters/credentials/windows-user-env.js +51 -0
  13. package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
  14. package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
  15. package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
  16. package/dist/adapters/jira-cloud/jira-client.js +137 -0
  17. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
  18. package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
  19. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
  20. package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
  21. package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
  22. package/dist/adapters/jira-cloud/mapper.js +143 -0
  23. package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
  24. package/dist/adapters/telemetry/console-telemetry.js +31 -0
  25. package/dist/application/get-full-issue-context.d.ts +18 -0
  26. package/dist/application/get-full-issue-context.js +120 -0
  27. package/dist/application/get-issue-context.d.ts +19 -0
  28. package/dist/application/get-issue-context.js +79 -0
  29. package/dist/application/search-issues.d.ts +22 -0
  30. package/dist/application/search-issues.js +93 -0
  31. package/dist/bootstrap/boot-health-gate.d.ts +25 -0
  32. package/dist/bootstrap/boot-health-gate.js +137 -0
  33. package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
  34. package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
  35. package/dist/bootstrap/host-mcp.d.ts +54 -0
  36. package/dist/bootstrap/host-mcp.js +98 -0
  37. package/dist/bootstrap/jira-projects.d.ts +16 -0
  38. package/dist/bootstrap/jira-projects.js +24 -0
  39. package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
  40. package/dist/bootstrap/mcp-config-merger.js +119 -0
  41. package/dist/bootstrap/migration-target.d.ts +60 -0
  42. package/dist/bootstrap/migration-target.js +80 -0
  43. package/dist/bootstrap/project-bindings.d.ts +48 -0
  44. package/dist/bootstrap/project-bindings.js +112 -0
  45. package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
  46. package/dist/bootstrap/project-config-bootstrapper.js +69 -0
  47. package/dist/bootstrap/project-config-resolver.d.ts +53 -0
  48. package/dist/bootstrap/project-config-resolver.js +59 -0
  49. package/dist/bootstrap/project-root-resolver.d.ts +22 -0
  50. package/dist/bootstrap/project-root-resolver.js +43 -0
  51. package/dist/bootstrap/setup-apply.d.ts +31 -0
  52. package/dist/bootstrap/setup-apply.js +61 -0
  53. package/dist/bootstrap/setup-plan.d.ts +102 -0
  54. package/dist/bootstrap/setup-plan.js +224 -0
  55. package/dist/bootstrap/setup-state.d.ts +73 -0
  56. package/dist/bootstrap/setup-state.js +89 -0
  57. package/dist/bootstrap/workspace-identity.d.ts +52 -0
  58. package/dist/bootstrap/workspace-identity.js +114 -0
  59. package/dist/cli/agent-api.d.ts +63 -0
  60. package/dist/cli/agent-api.js +149 -0
  61. package/dist/cli/auth.d.ts +39 -0
  62. package/dist/cli/auth.js +201 -0
  63. package/dist/cli/doctor.d.ts +7 -0
  64. package/dist/cli/doctor.js +31 -0
  65. package/dist/cli/runtime.d.ts +16 -0
  66. package/dist/cli/runtime.js +97 -0
  67. package/dist/cli/serve.d.ts +7 -0
  68. package/dist/cli/serve.js +23 -0
  69. package/dist/cli/setup-wizard.d.ts +37 -0
  70. package/dist/cli/setup-wizard.js +338 -0
  71. package/dist/cli/setup.d.ts +26 -0
  72. package/dist/cli/setup.js +214 -0
  73. package/dist/cli/ui.d.ts +118 -0
  74. package/dist/cli/ui.js +338 -0
  75. package/dist/cli-entry.d.ts +7 -0
  76. package/dist/cli-entry.js +124 -0
  77. package/dist/config/load-config.d.ts +18 -0
  78. package/dist/config/load-config.js +48 -0
  79. package/dist/config/schema.d.ts +36 -0
  80. package/dist/config/schema.js +74 -0
  81. package/dist/deps.d.ts +50 -0
  82. package/dist/deps.js +39 -0
  83. package/dist/domain/completeness.d.ts +63 -0
  84. package/dist/domain/completeness.js +20 -0
  85. package/dist/domain/context.d.ts +38 -0
  86. package/dist/domain/context.js +1 -0
  87. package/dist/domain/errors.d.ts +27 -0
  88. package/dist/domain/errors.js +49 -0
  89. package/dist/domain/issue.d.ts +35 -0
  90. package/dist/domain/issue.js +1 -0
  91. package/dist/index.d.ts +2 -0
  92. package/dist/index.js +17 -0
  93. package/dist/mcp/create-server.d.ts +9 -0
  94. package/dist/mcp/create-server.js +32 -0
  95. package/dist/mcp/tool-result.d.ts +10 -0
  96. package/dist/mcp/tool-result.js +33 -0
  97. package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
  98. package/dist/mcp/tools/jira-context.tool.js +25 -0
  99. package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
  100. package/dist/mcp/tools/jira-full.tool.js +27 -0
  101. package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
  102. package/dist/mcp/tools/jira-search.tool.js +31 -0
  103. package/dist/policy/completeness-policy.d.ts +19 -0
  104. package/dist/policy/completeness-policy.js +34 -0
  105. package/dist/policy/consistency-policy.d.ts +13 -0
  106. package/dist/policy/consistency-policy.js +6 -0
  107. package/dist/policy/field-policy.d.ts +11 -0
  108. package/dist/policy/field-policy.js +26 -0
  109. package/dist/policy/output-budget-policy.d.ts +23 -0
  110. package/dist/policy/output-budget-policy.js +96 -0
  111. package/dist/policy/pagination-policy.d.ts +15 -0
  112. package/dist/policy/pagination-policy.js +13 -0
  113. package/dist/ports/cache.port.d.ts +5 -0
  114. package/dist/ports/cache.port.js +1 -0
  115. package/dist/ports/credentials.port.d.ts +27 -0
  116. package/dist/ports/credentials.port.js +1 -0
  117. package/dist/ports/jira-read.port.d.ts +73 -0
  118. package/dist/ports/jira-read.port.js +1 -0
  119. package/dist/ports/jira-write.port.d.ts +12 -0
  120. package/dist/ports/jira-write.port.js +1 -0
  121. package/dist/ports/telemetry.port.d.ts +13 -0
  122. package/dist/ports/telemetry.port.js +1 -0
  123. package/package.json +69 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Atlassian Document Format -> plain text.
3
+ *
4
+ * ADF raw JSON is mostly structural tokens the agent has to pay for and then
5
+ * ignore. Normalizing to text is the single biggest payload win in JAM, so
6
+ * nothing downstream of the adapter ever sees an ADF node.
7
+ *
8
+ * Unknown node types are surfaced as `[unsupported: <type>]` rather than
9
+ * dropped - a silently missing paragraph is exactly the failure mode the
10
+ * completeness rules exist to prevent.
11
+ */
12
+ const KNOWN_LEAF = new Set(["hardBreak", "rule", "emoji", "date", "status", "inlineCard", "mention", "text"]);
13
+ export function adfToText(doc) {
14
+ if (doc == null)
15
+ return "";
16
+ if (typeof doc === "string")
17
+ return doc.trim();
18
+ if (typeof doc !== "object")
19
+ return String(doc);
20
+ const node = doc;
21
+ const out = renderNode(node, 0).replace(/\n{3,}/g, "\n\n").trim();
22
+ return out;
23
+ }
24
+ function renderChildren(node, depth, sep = "") {
25
+ if (!node.content?.length)
26
+ return "";
27
+ return node.content.map((c) => renderNode(c, depth)).join(sep);
28
+ }
29
+ function renderNode(node, depth) {
30
+ switch (node.type) {
31
+ case undefined:
32
+ case "doc":
33
+ return renderChildren(node, depth, "\n\n");
34
+ case "text":
35
+ return applyMarks(node.text ?? "", node.marks);
36
+ case "paragraph":
37
+ return renderChildren(node, depth);
38
+ case "heading": {
39
+ const level = Number(node.attrs?.["level"] ?? 1);
40
+ return `${"#".repeat(Math.min(6, Math.max(1, level)))} ${renderChildren(node, depth)}`;
41
+ }
42
+ case "bulletList":
43
+ case "orderedList": {
44
+ const ordered = node.type === "orderedList";
45
+ const items = node.content ?? [];
46
+ return items
47
+ .map((item, i) => {
48
+ const marker = ordered ? `${i + 1}.` : "-";
49
+ const body = renderNode(item, depth + 1).trim();
50
+ const indent = " ".repeat(depth);
51
+ // keep nested lines aligned under their marker
52
+ return `${indent}${marker} ${body.split("\n").join(`\n${indent} `)}`;
53
+ })
54
+ .join("\n");
55
+ }
56
+ case "listItem":
57
+ return renderChildren(node, depth, "\n");
58
+ case "taskList":
59
+ return renderChildren(node, depth, "\n");
60
+ case "taskItem": {
61
+ const done = node.attrs?.["state"] === "DONE";
62
+ return `- [${done ? "x" : " "}] ${renderChildren(node, depth)}`;
63
+ }
64
+ case "codeBlock": {
65
+ const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
66
+ return `\`\`\`${lang}\n${renderChildren(node, depth, "")}\n\`\`\``;
67
+ }
68
+ case "blockquote":
69
+ return renderChildren(node, depth, "\n")
70
+ .split("\n")
71
+ .map((l) => `> ${l}`)
72
+ .join("\n");
73
+ case "panel":
74
+ return renderChildren(node, depth, "\n\n");
75
+ case "rule":
76
+ return "---";
77
+ case "hardBreak":
78
+ return "\n";
79
+ case "mention": {
80
+ // Jira usually stores the "@" in attrs.text already - do not double it.
81
+ const raw = String(node.attrs?.["text"] ?? node.attrs?.["displayName"] ?? "unknown");
82
+ return raw.startsWith("@") ? raw : `@${raw}`;
83
+ }
84
+ case "emoji":
85
+ return String(node.attrs?.["text"] ?? node.attrs?.["shortName"] ?? "");
86
+ case "date":
87
+ return String(node.attrs?.["timestamp"] ?? "");
88
+ case "status":
89
+ return `[${node.attrs?.["text"] ?? ""}]`;
90
+ case "inlineCard":
91
+ case "blockCard":
92
+ case "embedCard":
93
+ return String(node.attrs?.["url"] ?? "[card]");
94
+ case "mediaSingle":
95
+ case "mediaGroup":
96
+ case "media":
97
+ return "[attachment]";
98
+ case "table":
99
+ return (node.content ?? []).map((row) => renderNode(row, depth)).join("\n");
100
+ case "tableRow":
101
+ return `| ${(node.content ?? [])
102
+ .map((cell) => renderNode(cell, depth).replace(/\n+/g, " ").trim())
103
+ .join(" | ")} |`;
104
+ case "tableHeader":
105
+ case "tableCell":
106
+ return renderChildren(node, depth, " ");
107
+ case "expand":
108
+ case "nestedExpand": {
109
+ const title = node.attrs?.["title"];
110
+ const body = renderChildren(node, depth, "\n\n");
111
+ return title ? `${title}\n${body}` : body;
112
+ }
113
+ default: {
114
+ const inner = renderChildren(node, depth, "\n");
115
+ const label = `[unsupported: ${node.type}]`;
116
+ if (!inner && !KNOWN_LEAF.has(node.type ?? ""))
117
+ return label;
118
+ return inner || label;
119
+ }
120
+ }
121
+ }
122
+ function applyMarks(text, marks) {
123
+ if (!marks?.length)
124
+ return text;
125
+ let out = text;
126
+ for (const mark of marks) {
127
+ switch (mark.type) {
128
+ case "code":
129
+ out = `\`${out}\``;
130
+ break;
131
+ case "strong":
132
+ out = `**${out}**`;
133
+ break;
134
+ case "em":
135
+ out = `*${out}*`;
136
+ break;
137
+ case "strike":
138
+ out = `~~${out}~~`;
139
+ break;
140
+ case "link": {
141
+ const href = mark.attrs?.["href"];
142
+ if (typeof href === "string" && href && href !== out)
143
+ out = `[${out}](${href})`;
144
+ break;
145
+ }
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+ return out;
151
+ }
@@ -0,0 +1,32 @@
1
+ import { JamError } from "../../domain/errors.js";
2
+ import type { CredentialPort } from "../../ports/credentials.port.js";
3
+ export type JiraRequest = {
4
+ path: string;
5
+ method?: "GET" | "POST";
6
+ query?: Record<string, string | number | undefined>;
7
+ body?: unknown;
8
+ };
9
+ export type JiraResponse<T> = {
10
+ data: T;
11
+ /** Size of the raw response body, for telemetry and budget accounting. */
12
+ bytes: number;
13
+ };
14
+ /**
15
+ * Thin HTTP boundary around Jira Cloud REST v3.
16
+ *
17
+ * Two hard rules live here:
18
+ * 1. The Authorization header is built at request time and never stored,
19
+ * logged, or attached to a thrown error.
20
+ * 2. Every non-2xx response becomes a JamError with a normalized code, so no
21
+ * vendor error shape escapes the adapter.
22
+ */
23
+ export declare class JiraClient {
24
+ private readonly credentials;
25
+ private readonly fetchImpl;
26
+ private cached?;
27
+ constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
28
+ get baseUrl(): string;
29
+ private creds;
30
+ request<T>(req: JiraRequest): Promise<JiraResponse<T>>;
31
+ }
32
+ export declare function mapStatus(status: number, body: string, path: string): JamError;
@@ -0,0 +1,137 @@
1
+ import { JamError } from "../../domain/errors.js";
2
+ const MAX_RETRIES = 2;
3
+ const MAX_RETRY_DELAY_MS = 5_000;
4
+ /**
5
+ * Thin HTTP boundary around Jira Cloud REST v3.
6
+ *
7
+ * Two hard rules live here:
8
+ * 1. The Authorization header is built at request time and never stored,
9
+ * logged, or attached to a thrown error.
10
+ * 2. Every non-2xx response becomes a JamError with a normalized code, so no
11
+ * vendor error shape escapes the adapter.
12
+ */
13
+ export class JiraClient {
14
+ credentials;
15
+ fetchImpl;
16
+ cached;
17
+ constructor(credentials, fetchImpl = fetch) {
18
+ this.credentials = credentials;
19
+ this.fetchImpl = fetchImpl;
20
+ }
21
+ get baseUrl() {
22
+ return this.creds().baseUrl;
23
+ }
24
+ creds() {
25
+ this.cached ??= this.credentials.load();
26
+ return this.cached;
27
+ }
28
+ async request(req) {
29
+ const creds = this.creds();
30
+ const url = new URL(req.path, `${creds.baseUrl}/`);
31
+ for (const [k, v] of Object.entries(req.query ?? {})) {
32
+ if (v !== undefined)
33
+ url.searchParams.set(k, String(v));
34
+ }
35
+ const auth = Buffer.from(`${creds.email}:${creds.apiToken}`).toString("base64");
36
+ let lastError;
37
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
38
+ let res;
39
+ try {
40
+ res = await this.fetchImpl(url, {
41
+ method: req.method ?? "GET",
42
+ headers: {
43
+ Authorization: `Basic ${auth}`,
44
+ Accept: "application/json",
45
+ ...(req.body ? { "Content-Type": "application/json" } : {}),
46
+ },
47
+ ...(req.body ? { body: JSON.stringify(req.body) } : {}),
48
+ });
49
+ }
50
+ catch (err) {
51
+ // Network-level failure: the message may contain the host but never the token.
52
+ throw new JamError("JIRA_UNAVAILABLE", `Could not reach Jira at ${creds.baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ const text = await res.text();
55
+ const bytes = Buffer.byteLength(text, "utf8");
56
+ if (res.ok) {
57
+ if (!text)
58
+ return { data: undefined, bytes };
59
+ try {
60
+ return { data: JSON.parse(text), bytes };
61
+ }
62
+ catch {
63
+ throw new JamError("JIRA_UNAVAILABLE", "Jira returned a non-JSON response.");
64
+ }
65
+ }
66
+ const error = mapStatus(res.status, text, url.pathname);
67
+ // ponytail: fixed 2 retries on transient statuses; add backoff tuning if
68
+ // `complete` searches start tripping Jira's rate limiter in practice.
69
+ const transient = res.status === 429 || res.status >= 500;
70
+ if (transient && attempt < MAX_RETRIES) {
71
+ lastError = error;
72
+ await sleep(retryDelayMs(res.headers.get("retry-after"), attempt));
73
+ continue;
74
+ }
75
+ throw error;
76
+ }
77
+ throw lastError ?? new JamError("JIRA_UNAVAILABLE", "Jira request failed.");
78
+ }
79
+ }
80
+ function retryDelayMs(retryAfter, attempt) {
81
+ const parsed = retryAfter ? Number(retryAfter) * 1000 : NaN;
82
+ const fallback = 500 * 2 ** attempt;
83
+ const delay = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
84
+ return Math.min(delay, MAX_RETRY_DELAY_MS);
85
+ }
86
+ function sleep(ms) {
87
+ return new Promise((r) => setTimeout(r, ms));
88
+ }
89
+ /**
90
+ * Extract Jira's own message without echoing the whole payload back to the agent.
91
+ */
92
+ function jiraMessage(body) {
93
+ try {
94
+ const parsed = JSON.parse(body);
95
+ const fromList = parsed.errorMessages?.filter(Boolean).join(" ");
96
+ if (fromList)
97
+ return fromList;
98
+ const fromMap = parsed.errors
99
+ ? Object.entries(parsed.errors)
100
+ .map(([k, v]) => `${k}: ${v}`)
101
+ .join(" ")
102
+ : "";
103
+ if (fromMap)
104
+ return fromMap;
105
+ if (parsed.message)
106
+ return parsed.message;
107
+ }
108
+ catch {
109
+ // fall through - a non-JSON error body is not worth forwarding verbatim
110
+ }
111
+ return undefined;
112
+ }
113
+ export function mapStatus(status, body, path) {
114
+ const detail = jiraMessage(body);
115
+ const suffix = detail ? ` ${detail}` : "";
116
+ switch (status) {
117
+ case 400:
118
+ return new JamError("JQL_INVALID", `Jira rejected the request as malformed.${suffix}`, { status });
119
+ case 401:
120
+ return new JamError("JIRA_AUTH_FAILED", "Jira rejected the credentials. Check JIRA_EMAIL and JIRA_API_TOKEN.", { status });
121
+ case 403:
122
+ return new JamError("JIRA_PERMISSION_DENIED", `The current Jira account is not permitted to do this.${suffix}`, { status });
123
+ case 404:
124
+ return new JamError("ISSUE_NOT_FOUND", `Jira has no such resource, or it is not visible to this account (${path}).${suffix}`, { status });
125
+ case 429:
126
+ return new JamError("RATE_LIMITED", "Jira rate limit reached.", { status });
127
+ default:
128
+ if (status >= 500) {
129
+ return new JamError("JIRA_UNAVAILABLE", `Jira returned ${status}.${suffix}`, {
130
+ status,
131
+ });
132
+ }
133
+ return new JamError("JIRA_UNAVAILABLE", `Jira returned ${status}.${suffix}`, {
134
+ status,
135
+ });
136
+ }
137
+ }
@@ -0,0 +1,13 @@
1
+ import type { ProjectConfig } from "../../config/schema.js";
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";
4
+ export declare class JiraCloudReadAdapter implements JiraReadPort {
5
+ private readonly config;
6
+ private readonly client;
7
+ constructor(credentials: CredentialPort, config: ProjectConfig, fetchImpl?: typeof fetch);
8
+ searchPage(req: SearchPageRequest): Promise<SearchPageResult>;
9
+ getIssues(req: GetIssuesRequest): Promise<GetIssuesResult>;
10
+ getComments(req: GetCommentsRequest): Promise<GetCommentsResult>;
11
+ listProjects(): Promise<ListProjectsResult>;
12
+ getCurrentUser(): Promise<CurrentUser>;
13
+ }
@@ -0,0 +1,97 @@
1
+ import { JiraClient } from "./jira-client.js";
2
+ import { mapComment, mapIssueWithMeta } from "./mapper.js";
3
+ /** Jira Cloud caps a bulkfetch request at 100 keys. */
4
+ const BULK_CHUNK = 100;
5
+ export class JiraCloudReadAdapter {
6
+ config;
7
+ client;
8
+ constructor(credentials, config, fetchImpl) {
9
+ this.config = config;
10
+ this.client = new JiraClient(credentials, fetchImpl);
11
+ }
12
+ async searchPage(req) {
13
+ const { data, bytes } = await this.client.request({
14
+ path: "rest/api/3/search/jql",
15
+ query: {
16
+ jql: req.jql,
17
+ fields: req.fields.join(","),
18
+ maxResults: req.pageSize,
19
+ nextPageToken: req.pageToken,
20
+ },
21
+ });
22
+ const result = {
23
+ issues: (data.issues ?? []).map((raw) => mapIssueWithMeta(raw, this.config).issue),
24
+ responseBytes: bytes,
25
+ };
26
+ if (data.nextPageToken)
27
+ result.nextPageToken = data.nextPageToken;
28
+ return result;
29
+ }
30
+ async getIssues(req) {
31
+ const issues = [];
32
+ const commentTotals = {};
33
+ let responseBytes = 0;
34
+ const returned = new Set();
35
+ for (const chunk of chunks(req.keys, BULK_CHUNK)) {
36
+ const { data, bytes } = await this.client.request({
37
+ path: "rest/api/3/issue/bulkfetch",
38
+ method: "POST",
39
+ body: {
40
+ issueIdsOrKeys: chunk,
41
+ fields: req.fields,
42
+ fieldsByKeys: true,
43
+ },
44
+ });
45
+ responseBytes += bytes;
46
+ for (const raw of data.issues ?? []) {
47
+ const { issue, commentTotal } = mapIssueWithMeta(raw, this.config);
48
+ issues.push(issue);
49
+ commentTotals[issue.key] = commentTotal;
50
+ returned.add(issue.key.toUpperCase());
51
+ }
52
+ }
53
+ // bulkfetch reports unreadable keys in `issueErrors`, but a key can also be
54
+ // silently absent. Diffing requested against returned catches both.
55
+ const missingKeys = req.keys.filter((k) => !returned.has(k.toUpperCase()));
56
+ return { issues, missingKeys, commentTotals, responseBytes };
57
+ }
58
+ async getComments(req) {
59
+ const { data, bytes } = await this.client.request({
60
+ path: `rest/api/3/issue/${encodeURIComponent(req.key)}/comment`,
61
+ query: {
62
+ startAt: req.startAt,
63
+ maxResults: req.maxResults,
64
+ orderBy: "created",
65
+ },
66
+ });
67
+ return {
68
+ comments: (data.comments ?? []).map(mapComment),
69
+ startAt: data.startAt ?? req.startAt,
70
+ total: data.total ?? 0,
71
+ responseBytes: bytes,
72
+ };
73
+ }
74
+ async listProjects() {
75
+ const { data } = await this.client.request({ path: "rest/api/3/project/search", query: { maxResults: 50 } });
76
+ return {
77
+ projects: (data.values ?? [])
78
+ .filter((p) => Boolean(p.key && p.name))
79
+ .map((p) => ({ key: p.key, name: p.name })),
80
+ truncated: data.isLast === false,
81
+ };
82
+ }
83
+ async getCurrentUser() {
84
+ const { data } = await this.client.request({ path: "rest/api/3/myself" });
85
+ const user = { accountId: data.accountId ?? "" };
86
+ if (data.displayName)
87
+ user.displayName = data.displayName;
88
+ if (data.emailAddress)
89
+ user.emailAddress = data.emailAddress;
90
+ return user;
91
+ }
92
+ }
93
+ function* chunks(items, size) {
94
+ for (let i = 0; i < items.length; i += size) {
95
+ yield items.slice(i, i + size);
96
+ }
97
+ }
@@ -0,0 +1,12 @@
1
+ import type { JiraWritePort } from "../../ports/jira-write.port.js";
2
+ /**
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.
6
+ */
7
+ export declare class UnsupportedJiraWriteAdapter implements JiraWritePort {
8
+ updateIssue(): Promise<void>;
9
+ addComment(): Promise<{
10
+ id: string;
11
+ }>;
12
+ }
@@ -0,0 +1,17 @@
1
+ import { JamError } from "../../domain/errors.js";
2
+ /**
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.
6
+ */
7
+ export class UnsupportedJiraWriteAdapter {
8
+ async updateIssue() {
9
+ throw notSupported();
10
+ }
11
+ async addComment() {
12
+ throw notSupported();
13
+ }
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
+ }
@@ -0,0 +1,34 @@
1
+ import type { FullIssueContext, NormalizedComment } from "../../domain/context.js";
2
+ import type { ProjectConfig } from "../../config/schema.js";
3
+ /**
4
+ * Raw Jira DTOs stop here. Everything above the adapter sees domain types only,
5
+ * so swapping REST for Rovo later cannot ripple into the application layer.
6
+ */
7
+ type RawUser = {
8
+ displayName?: string;
9
+ emailAddress?: string;
10
+ accountId?: string;
11
+ };
12
+ export type RawIssue = {
13
+ key?: string;
14
+ id?: string;
15
+ fields?: Record<string, unknown>;
16
+ };
17
+ export type RawComment = {
18
+ id?: string;
19
+ author?: RawUser;
20
+ created?: string;
21
+ updated?: string;
22
+ body?: unknown;
23
+ };
24
+ export type MappedIssue = {
25
+ issue: FullIssueContext;
26
+ /** Total comments Jira reports for this issue, not just the embedded page. */
27
+ commentTotal: number;
28
+ };
29
+ export declare function mapIssue(raw: RawIssue, config: ProjectConfig): FullIssueContext;
30
+ export declare function mapIssueWithMeta(raw: RawIssue, config: ProjectConfig): MappedIssue;
31
+ export declare function mapComment(raw: RawComment): NormalizedComment;
32
+ /** Collapse Jira's option/user/ADF wrappers into something worth a token. */
33
+ export declare function normalizeFieldValue(value: unknown): unknown;
34
+ export {};
@@ -0,0 +1,143 @@
1
+ import { adfToText } from "./adf-to-text.js";
2
+ export function mapIssue(raw, config) {
3
+ return mapIssueWithMeta(raw, config).issue;
4
+ }
5
+ export function mapIssueWithMeta(raw, config) {
6
+ const f = (raw.fields ?? {});
7
+ const issue = {
8
+ key: raw.key ?? "",
9
+ summary: str(f["summary"]) ?? "",
10
+ status: named(f["status"]) ?? "",
11
+ updated: str(f["updated"]) ?? "",
12
+ labels: Array.isArray(f["labels"]) ? f["labels"].map(String) : [],
13
+ components: Array.isArray(f["components"])
14
+ ? f["components"].map((c) => c.name ?? "").filter(Boolean)
15
+ : [],
16
+ subtasks: Array.isArray(f["subtasks"])
17
+ ? f["subtasks"].map(issueRef)
18
+ : [],
19
+ links: Array.isArray(f["issuelinks"])
20
+ ? f["issuelinks"].flatMap(mapLink)
21
+ : [],
22
+ customFields: mapCustomFields(f, config),
23
+ comments: [],
24
+ };
25
+ const assignee = user(f["assignee"]);
26
+ if (assignee)
27
+ issue.assignee = assignee;
28
+ const priority = named(f["priority"]);
29
+ if (priority)
30
+ issue.priority = priority;
31
+ const issueType = named(f["issuetype"]);
32
+ if (issueType)
33
+ issue.issueType = issueType;
34
+ if (f["parent"])
35
+ issue.parent = issueRef(f["parent"]);
36
+ if (f["description"] != null) {
37
+ const description = adfToText(f["description"]);
38
+ if (description)
39
+ issue.description = description;
40
+ }
41
+ // Issue GET / bulkfetch embed only the first page of comments; the adapter
42
+ // pages the rest through the dedicated comment endpoint when FULL is asked for.
43
+ const embedded = f["comment"];
44
+ if (embedded?.comments) {
45
+ issue.comments = embedded.comments.map(mapComment);
46
+ }
47
+ const commentTotal = embedded?.total ?? issue.comments.length;
48
+ return { issue, commentTotal };
49
+ }
50
+ export function mapComment(raw) {
51
+ const comment = {
52
+ id: raw.id ?? "",
53
+ created: raw.created ?? "",
54
+ body: adfToText(raw.body),
55
+ };
56
+ const author = raw.author?.displayName ?? raw.author?.emailAddress;
57
+ if (author)
58
+ comment.author = author;
59
+ if (raw.updated && raw.updated !== raw.created)
60
+ comment.updated = raw.updated;
61
+ return comment;
62
+ }
63
+ function mapLink(raw) {
64
+ const type = raw.type ?? {};
65
+ const out = [];
66
+ const add = (direction, target) => {
67
+ if (!target?.key)
68
+ return;
69
+ const label = (direction === "outward" ? type.outward : type.inward) ?? type.name ?? "relates to";
70
+ out.push({
71
+ type: label,
72
+ direction,
73
+ issue: issueRef(target),
74
+ blocksThisIssue: isBlocking(label, direction, type.name),
75
+ });
76
+ };
77
+ add("outward", raw.outwardIssue);
78
+ add("inward", raw.inwardIssue);
79
+ return out;
80
+ }
81
+ /**
82
+ * True when the linked issue stands between this issue and being startable.
83
+ * Jira phrases this from the current issue's side, e.g. "is blocked by".
84
+ */
85
+ function isBlocking(label, direction, typeName) {
86
+ if (/blocked by|depends on|is caused by/i.test(label))
87
+ return true;
88
+ return direction === "inward" && /^blocks$/i.test(typeName ?? "");
89
+ }
90
+ function mapCustomFields(fields, config) {
91
+ const out = {};
92
+ for (const cf of config.customFields) {
93
+ const value = fields[cf.id];
94
+ if (value == null)
95
+ continue;
96
+ const normalized = normalizeFieldValue(value);
97
+ if (normalized == null || normalized === "")
98
+ continue;
99
+ out[cf.name] = normalized;
100
+ }
101
+ return out;
102
+ }
103
+ /** Collapse Jira's option/user/ADF wrappers into something worth a token. */
104
+ export function normalizeFieldValue(value) {
105
+ if (value == null)
106
+ return undefined;
107
+ if (typeof value !== "object")
108
+ return value;
109
+ if (Array.isArray(value)) {
110
+ const items = value.map(normalizeFieldValue).filter((v) => v != null && v !== "");
111
+ return items.length ? items : undefined;
112
+ }
113
+ const obj = value;
114
+ if (obj["type"] === "doc")
115
+ return adfToText(obj);
116
+ if (typeof obj["value"] === "string")
117
+ return obj["value"];
118
+ if (typeof obj["displayName"] === "string")
119
+ return obj["displayName"];
120
+ if (typeof obj["name"] === "string")
121
+ return obj["name"];
122
+ return obj;
123
+ }
124
+ function issueRef(raw) {
125
+ const ref = { key: raw.key ?? "" };
126
+ const summary = raw.fields?.summary;
127
+ if (summary)
128
+ ref.summary = summary;
129
+ const status = raw.fields?.status?.name;
130
+ if (status)
131
+ ref.status = status;
132
+ return ref;
133
+ }
134
+ function str(v) {
135
+ return typeof v === "string" ? v : undefined;
136
+ }
137
+ function named(v) {
138
+ return v?.name;
139
+ }
140
+ function user(v) {
141
+ const u = v;
142
+ return u?.displayName ?? u?.emailAddress ?? undefined;
143
+ }
@@ -0,0 +1,13 @@
1
+ import type { TelemetryPort, ToolMetrics } from "../../ports/telemetry.port.js";
2
+ /**
3
+ * One key=value line per tool call on stderr. stdout is reserved for the MCP
4
+ * stdio protocol, so nothing here may ever write to stdout.
5
+ *
6
+ * Only the fields below are emitted - never credentials, headers, JQL bind
7
+ * values, or issue content.
8
+ */
9
+ export declare class ConsoleTelemetry implements TelemetryPort {
10
+ private readonly enabled;
11
+ constructor(enabled?: boolean);
12
+ recordTool(m: ToolMetrics): void;
13
+ }