@jam-mcp/server 1.3.0 → 1.3.2
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.
- package/LICENSE +21 -21
- package/README.md +90 -86
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.d.ts +25 -0
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.js +84 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/bootstrap/setup-plan.d.ts +11 -0
- package/dist/bootstrap/setup-plan.js +10 -1
- package/dist/cli-entry.js +33 -33
- package/dist/index.js +0 -0
- package/dist/mcp/tools/jira-write-apply.tool.js +14 -14
- package/dist/mcp/tools/jira-write-plan.tool.js +21 -21
- package/dist/policy/custom-field-policy.d.ts +93 -0
- package/dist/policy/custom-field-policy.js +230 -0
- package/dist/ports/jira-edit-metadata.port.d.ts +22 -0
- package/dist/ports/jira-edit-metadata.port.js +1 -0
- 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,86 +1,90 @@
|
|
|
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
|
-
|
|
35
|
-
`field.update` (summary, priority, labels, components),
|
|
36
|
-
(matched against the transitions Jira currently offers, never a guessed id)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 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
|
+
Five operations: `comment.add` (plain text, converted to ADF here),
|
|
35
|
+
`field.update` (summary, priority, labels, components), `status.transition`
|
|
36
|
+
(matched against the transitions Jira currently offers, never a guessed id),
|
|
37
|
+
`assignee.update` (resolved to an account by exact display name or accountId,
|
|
38
|
+
never a substring match), and `issue.create` (planned against the project's own
|
|
39
|
+
create schema, so an unavailable issue type is a refusal here rather than a
|
|
40
|
+
Jira 400 later).
|
|
41
|
+
|
|
42
|
+
## Evidence boundary
|
|
43
|
+
|
|
44
|
+
Every result carries a `meta` block, and nothing is ever truncated silently.
|
|
45
|
+
|
|
46
|
+
`meta.complete` means **JAM finished the Jira retrieval with no known loss**. It
|
|
47
|
+
is not a statement about the project: not readiness, not "unblocked", not "the
|
|
48
|
+
whole story". What JAM did not look at is named in the same block —
|
|
49
|
+
`evidenceScope` and `limitations` call out the repository, external sources, and
|
|
50
|
+
dependencies that live outside Jira. `provenance` and `source` say where the
|
|
51
|
+
records came from.
|
|
52
|
+
|
|
53
|
+
So an empty comment thread on a complete read is a complete read *of Jira*, not
|
|
54
|
+
proof that nothing was agreed. If the issue points at an external canonical
|
|
55
|
+
source, that source is what settles the question.
|
|
56
|
+
|
|
57
|
+
## Running it
|
|
58
|
+
|
|
59
|
+
Don't install this package to use JAM. Your coding agent launches
|
|
60
|
+
[`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
|
|
61
|
+
reads your `~/.jam/config.yaml`, decides which JAM build this machine should
|
|
62
|
+
run, and dispatches here. Naming the server directly instead pins one machine
|
|
63
|
+
to one build and bypasses that choice — and it is what keeps a committed
|
|
64
|
+
`.mcp.json` free of machine-specific paths.
|
|
65
|
+
|
|
66
|
+
The same binary carries the CLI, and every command below is reachable through
|
|
67
|
+
the launcher:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
serve Run the MCP server over stdio
|
|
71
|
+
doctor Diagnose config, credentials and Jira connectivity
|
|
72
|
+
setup Wire up JAM and verify (personal by default; --shared for the team)
|
|
73
|
+
auth login Store Jira credentials in this user's OS secret store
|
|
74
|
+
runtime Show or change which JAM build this machine runs
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Written out, that is `npx --yes @jam-mcp/launcher@1.3.2 doctor`, or just `jam
|
|
78
|
+
doctor` if you took the launcher's optional global install. Starting from
|
|
79
|
+
nothing — no install, no runtime chosen yet — use
|
|
80
|
+
`npx --yes @jam-mcp/bootstrap@1.3.2 init` instead.
|
|
81
|
+
|
|
82
|
+
Credentials come from the process environment or this user's OS secret store —
|
|
83
|
+
never from a repository file — and never appear in logs, telemetry, or tool
|
|
84
|
+
results. `stdout` is reserved for the MCP protocol and for JSON output;
|
|
85
|
+
diagnostics go to `stderr`.
|
|
86
|
+
|
|
87
|
+
## More
|
|
88
|
+
|
|
89
|
+
- [Repository README](https://github.com/colosair/jam#readme)
|
|
90
|
+
- [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EditFieldMetadata } from "../../domain/write.js";
|
|
2
|
+
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
+
import type { JiraEditMetadataPort } from "../../ports/jira-edit-metadata.port.js";
|
|
4
|
+
/**
|
|
5
|
+
* Jira Cloud REST v3 edit metadata for one issue.
|
|
6
|
+
*
|
|
7
|
+
* `GET /rest/api/3/issue/{key}/editmeta`, `retry: false`. Its answer decides
|
|
8
|
+
* whether a mutation may proceed and what shape it takes, so a
|
|
9
|
+
* retried-and-stale answer is worse than a failure - the same argument that
|
|
10
|
+
* keeps `getTransitions`, the create metadata calls and the assignability
|
|
11
|
+
* check on the non-retrying side.
|
|
12
|
+
*
|
|
13
|
+
* Jira keys the response by field id, and describes each field with a `schema`
|
|
14
|
+
* and a list of `operations`. Both travel, because both are what the decision
|
|
15
|
+
* is made in; the rest of the document does not.
|
|
16
|
+
*
|
|
17
|
+
* Anything JAM cannot read is dropped rather than half-understood. A field
|
|
18
|
+
* that survives here with the wrong shape would be a field JAM claims to
|
|
19
|
+
* understand well enough to write.
|
|
20
|
+
*/
|
|
21
|
+
export declare class JiraCloudEditMetadataAdapter implements JiraEditMetadataPort {
|
|
22
|
+
private readonly client;
|
|
23
|
+
constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
|
|
24
|
+
getEditableFields(issueKey: string): Promise<EditFieldMetadata[]>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { JiraClient } from "./jira-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Jira Cloud REST v3 edit metadata for one issue.
|
|
4
|
+
*
|
|
5
|
+
* `GET /rest/api/3/issue/{key}/editmeta`, `retry: false`. Its answer decides
|
|
6
|
+
* whether a mutation may proceed and what shape it takes, so a
|
|
7
|
+
* retried-and-stale answer is worse than a failure - the same argument that
|
|
8
|
+
* keeps `getTransitions`, the create metadata calls and the assignability
|
|
9
|
+
* check on the non-retrying side.
|
|
10
|
+
*
|
|
11
|
+
* Jira keys the response by field id, and describes each field with a `schema`
|
|
12
|
+
* and a list of `operations`. Both travel, because both are what the decision
|
|
13
|
+
* is made in; the rest of the document does not.
|
|
14
|
+
*
|
|
15
|
+
* Anything JAM cannot read is dropped rather than half-understood. A field
|
|
16
|
+
* that survives here with the wrong shape would be a field JAM claims to
|
|
17
|
+
* understand well enough to write.
|
|
18
|
+
*/
|
|
19
|
+
export class JiraCloudEditMetadataAdapter {
|
|
20
|
+
client;
|
|
21
|
+
constructor(credentials, fetchImpl) {
|
|
22
|
+
this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
|
|
23
|
+
}
|
|
24
|
+
async getEditableFields(issueKey) {
|
|
25
|
+
const { data } = await this.client.request({
|
|
26
|
+
path: `rest/api/3/issue/${encodeURIComponent(issueKey)}/editmeta`,
|
|
27
|
+
retry: false,
|
|
28
|
+
});
|
|
29
|
+
const fields = data?.fields;
|
|
30
|
+
if (!fields || typeof fields !== "object")
|
|
31
|
+
return [];
|
|
32
|
+
return Object.entries(fields)
|
|
33
|
+
.map(([id, raw]) => toEditField(id, raw))
|
|
34
|
+
.filter((f) => f !== undefined);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function toEditField(id, raw) {
|
|
38
|
+
if (!raw || typeof raw !== "object")
|
|
39
|
+
return undefined;
|
|
40
|
+
// A field with no schema type is a field JAM cannot classify, and an
|
|
41
|
+
// unclassifiable field is one it must not decide it can write.
|
|
42
|
+
const type = typeof raw.schema?.type === "string" ? raw.schema.type : undefined;
|
|
43
|
+
if (!type)
|
|
44
|
+
return undefined;
|
|
45
|
+
const allowed = toOptions(raw.allowedValues);
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
name: typeof raw.name === "string" ? raw.name : id,
|
|
49
|
+
required: raw.required === true,
|
|
50
|
+
operations: Array.isArray(raw.operations)
|
|
51
|
+
? raw.operations.filter((op) => typeof op === "string")
|
|
52
|
+
: [],
|
|
53
|
+
schema: {
|
|
54
|
+
type,
|
|
55
|
+
...(typeof raw.schema?.items === "string" ? { items: raw.schema.items } : {}),
|
|
56
|
+
...(typeof raw.schema?.custom === "string" ? { custom: raw.schema.custom } : {}),
|
|
57
|
+
...(typeof raw.schema?.customId === "number" ? { customId: raw.schema.customId } : {}),
|
|
58
|
+
},
|
|
59
|
+
...(allowed ? { allowedValues: allowed } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The options Jira offers, when it constrains the field at all.
|
|
64
|
+
*
|
|
65
|
+
* Absent and empty mean different things and stay apart: absent is "Jira did
|
|
66
|
+
* not constrain this", empty is "Jira constrains it and offers nothing". The
|
|
67
|
+
* first permits a free value, the second permits none.
|
|
68
|
+
*
|
|
69
|
+
* Jira labels an option `value` on a select and `name` on some other pickers.
|
|
70
|
+
* Both are read; an option with neither an id nor a label is dropped, because
|
|
71
|
+
* it can be neither chosen nor recognised afterwards.
|
|
72
|
+
*/
|
|
73
|
+
function toOptions(raw) {
|
|
74
|
+
if (!Array.isArray(raw))
|
|
75
|
+
return undefined;
|
|
76
|
+
return raw
|
|
77
|
+
.map((entry) => {
|
|
78
|
+
const o = entry;
|
|
79
|
+
const id = typeof o?.id === "string" ? o.id : typeof o?.id === "number" ? String(o.id) : undefined;
|
|
80
|
+
const label = typeof o?.value === "string" ? o.value : typeof o?.name === "string" ? o.name : undefined;
|
|
81
|
+
return id && label ? { id, label } : undefined;
|
|
82
|
+
})
|
|
83
|
+
.filter((o) => o !== undefined);
|
|
84
|
+
}
|
|
@@ -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.3.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.3.2", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
|
@@ -64,10 +64,21 @@ export type SetupPlan = {
|
|
|
64
64
|
* configured - so it is an `npx` bootstrap invocation, never a bare `jam`.
|
|
65
65
|
* A human interface is free to render the short form; this field is the one
|
|
66
66
|
* a script runs, and a script has no PATH to rely on.
|
|
67
|
+
*
|
|
68
|
+
* `userCommand` is the opposite: a command for the person, which the agent
|
|
69
|
+
* relays and never runs. Authentication is the only step of that shape, and
|
|
70
|
+
* it carries no `command` precisely so that no caller can execute it. The
|
|
71
|
+
* separation is the point - one field is for running, the other for showing.
|
|
72
|
+
*
|
|
73
|
+
* `env` names the variables that would satisfy the same requirement without
|
|
74
|
+
* the interactive command, so an agent that cannot show a prompt still knows
|
|
75
|
+
* what the person has to provide - never their values.
|
|
67
76
|
*/
|
|
68
77
|
nextAction?: {
|
|
69
78
|
type: "authenticate" | "select_project" | "configure_runtime";
|
|
70
79
|
command?: string;
|
|
80
|
+
userCommand?: string;
|
|
81
|
+
env?: string[];
|
|
71
82
|
};
|
|
72
83
|
project?: {
|
|
73
84
|
root: string;
|
|
@@ -113,7 +113,16 @@ function finish(changes, state, project) {
|
|
|
113
113
|
code: "JAM_AUTH_REQUIRED",
|
|
114
114
|
changes,
|
|
115
115
|
requiresUserAction: true,
|
|
116
|
-
nextAction: {
|
|
116
|
+
nextAction: {
|
|
117
|
+
type: "authenticate",
|
|
118
|
+
// Deliberately no `command`: an agent must not run the login, and the
|
|
119
|
+
// absence is what stops it. `userCommand` is what it hands the person
|
|
120
|
+
// instead - previously that instruction existed only in CLI prose, so
|
|
121
|
+
// an agent reading the JSON alone knew a human was needed but not for
|
|
122
|
+
// what.
|
|
123
|
+
userCommand: portableBootstrapCommand("auth login"),
|
|
124
|
+
env: ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"],
|
|
125
|
+
},
|
|
117
126
|
project,
|
|
118
127
|
};
|
|
119
128
|
}
|
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/index.js
CHANGED
|
File without changes
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { applyWritePlan } from "../../application/apply-write.js";
|
|
3
3
|
import { runTool } from "../tool-result.js";
|
|
4
|
-
const DESCRIPTION = `Apply a plan from jira_write_plan. This changes Jira.
|
|
5
|
-
|
|
6
|
-
Takes a planId and nothing else. The change was decided when the plan was made, so there is no field, payload or override to pass here - that is deliberate, and it is what stops a write happening without the state check that planning did.
|
|
7
|
-
|
|
8
|
-
Before writing, JAM re-reads the issue and compares it to what the plan saw. If it moved, you get JAM_WRITE_CONFLICT and no write happens: call jira_write_plan again against the new state rather than treating the conflict as a transient failure.
|
|
9
|
-
|
|
10
|
-
After writing, JAM reads the issue back and checks the intended result is actually there. Only then does it return "applied". Jira accepting a request is not the same as the issue having changed.
|
|
11
|
-
|
|
12
|
-
Failures worth handling differently:
|
|
13
|
-
- JAM_WRITE_CONFLICT the issue moved; re-plan
|
|
14
|
-
- JAM_WRITE_PLAN_EXPIRED the plan aged out; re-plan
|
|
15
|
-
- JAM_WRITE_VERIFICATION_FAILED Jira accepted it but the issue does not show it; read the issue and tell the user
|
|
16
|
-
- JAM_WRITE_UNCERTAIN JAM does not know whether it landed; read the issue. Do NOT call this tool again - the write may already have been applied, and applying it twice is a second comment or a second transition.
|
|
17
|
-
|
|
4
|
+
const DESCRIPTION = `Apply a plan from jira_write_plan. This changes Jira.
|
|
5
|
+
|
|
6
|
+
Takes a planId and nothing else. The change was decided when the plan was made, so there is no field, payload or override to pass here - that is deliberate, and it is what stops a write happening without the state check that planning did.
|
|
7
|
+
|
|
8
|
+
Before writing, JAM re-reads the issue and compares it to what the plan saw. If it moved, you get JAM_WRITE_CONFLICT and no write happens: call jira_write_plan again against the new state rather than treating the conflict as a transient failure.
|
|
9
|
+
|
|
10
|
+
After writing, JAM reads the issue back and checks the intended result is actually there. Only then does it return "applied". Jira accepting a request is not the same as the issue having changed.
|
|
11
|
+
|
|
12
|
+
Failures worth handling differently:
|
|
13
|
+
- JAM_WRITE_CONFLICT the issue moved; re-plan
|
|
14
|
+
- JAM_WRITE_PLAN_EXPIRED the plan aged out; re-plan
|
|
15
|
+
- JAM_WRITE_VERIFICATION_FAILED Jira accepted it but the issue does not show it; read the issue and tell the user
|
|
16
|
+
- JAM_WRITE_UNCERTAIN JAM does not know whether it landed; read the issue. Do NOT call this tool again - the write may already have been applied, and applying it twice is a second comment or a second transition.
|
|
17
|
+
|
|
18
18
|
Never report an uncertain or unverified write as done.`;
|
|
19
19
|
export function registerJiraWriteApply(server, deps) {
|
|
20
20
|
server.registerTool("jira_write_apply", {
|
|
@@ -2,27 +2,27 @@ import { z } from "zod";
|
|
|
2
2
|
import { planWrite } from "../../application/plan-write.js";
|
|
3
3
|
import { CREATABLE_FIELDS, WRITABLE_FIELDS, WRITE_OPERATIONS } from "../../domain/write.js";
|
|
4
4
|
import { runTool } from "../tool-result.js";
|
|
5
|
-
const DESCRIPTION = `Work out how to change one Jira issue, and get back a plan. Changes nothing.
|
|
6
|
-
|
|
7
|
-
This is the first half of every write. Call it, read what it says the issue looks like now and what it would become, then pass the returned planId to jira_write_apply. There is no way to write to Jira without a plan, and a plan cannot be assembled by hand - only jira_write_plan issues one.
|
|
8
|
-
|
|
9
|
-
Operations on an issue that already exists - these need \`key\`:
|
|
10
|
-
- comment.add input: { "text": "..." } plain text; JAM converts it, do not send ADF
|
|
11
|
-
- field.update input: { "summary"?, "priority"?, "labels"?, "components"? }
|
|
12
|
-
- status.transition input: { "status": "Done" } JAM asks Jira which transitions exist and matches yours
|
|
13
|
-
- assignee.update input: { "assignee": "..." } a display name or an accountId; JAM resolves it against Jira's own directory
|
|
14
|
-
|
|
15
|
-
Creating an issue - no \`key\`, because there is no issue yet:
|
|
16
|
-
- issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
|
|
17
|
-
|
|
18
|
-
issue.create goes into the project this workspace is bound to; the project is not a parameter. Planning reads Jira's create schema for that project first, so an issue type Jira does not offer, a priority or component outside its allowed values, and a project whose create screen requires a field JAM cannot set are all refused here rather than attempted. \`description\` is plain text, like a comment. Not settable in this version: assignee, reporter, parent, custom fields, attachments.
|
|
19
|
-
|
|
20
|
-
assignee.update never sends the name you pass. JAM searches Jira's user directory, and assigns only when exactly one user matches your string exactly - an exact display name (case-insensitive) or an accountId. A partial match is Jira reporting a similarity, not identifying a person, so several matches or none come back as a refusal with the candidates attached: name one exactly, or pass their accountId. JAM also checks Jira offers that person as an assignee for this issue, before planning and again before writing, and confirms the result by accountId rather than by name. Unassigning, and setting an assignee while creating, are not in this version.
|
|
21
|
-
|
|
22
|
-
Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
|
|
23
|
-
|
|
24
|
-
The plan records what the issue looked like when it was made, and expires. If the issue changes in the meantime, jira_write_apply refuses with JAM_WRITE_CONFLICT - re-plan against the new state rather than forcing the old one through.
|
|
25
|
-
|
|
5
|
+
const DESCRIPTION = `Work out how to change one Jira issue, and get back a plan. Changes nothing.
|
|
6
|
+
|
|
7
|
+
This is the first half of every write. Call it, read what it says the issue looks like now and what it would become, then pass the returned planId to jira_write_apply. There is no way to write to Jira without a plan, and a plan cannot be assembled by hand - only jira_write_plan issues one.
|
|
8
|
+
|
|
9
|
+
Operations on an issue that already exists - these need \`key\`:
|
|
10
|
+
- comment.add input: { "text": "..." } plain text; JAM converts it, do not send ADF
|
|
11
|
+
- field.update input: { "summary"?, "priority"?, "labels"?, "components"? }
|
|
12
|
+
- status.transition input: { "status": "Done" } JAM asks Jira which transitions exist and matches yours
|
|
13
|
+
- assignee.update input: { "assignee": "..." } a display name or an accountId; JAM resolves it against Jira's own directory
|
|
14
|
+
|
|
15
|
+
Creating an issue - no \`key\`, because there is no issue yet:
|
|
16
|
+
- issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
|
|
17
|
+
|
|
18
|
+
issue.create goes into the project this workspace is bound to; the project is not a parameter. Planning reads Jira's create schema for that project first, so an issue type Jira does not offer, a priority or component outside its allowed values, and a project whose create screen requires a field JAM cannot set are all refused here rather than attempted. \`description\` is plain text, like a comment. Not settable in this version: assignee, reporter, parent, custom fields, attachments.
|
|
19
|
+
|
|
20
|
+
assignee.update never sends the name you pass. JAM searches Jira's user directory, and assigns only when exactly one user matches your string exactly - an exact display name (case-insensitive) or an accountId. A partial match is Jira reporting a similarity, not identifying a person, so several matches or none come back as a refusal with the candidates attached: name one exactly, or pass their accountId. JAM also checks Jira offers that person as an assignee for this issue, before planning and again before writing, and confirms the result by accountId rather than by name. Unassigning, and setting an assignee while creating, are not in this version.
|
|
21
|
+
|
|
22
|
+
Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
|
|
23
|
+
|
|
24
|
+
The plan records what the issue looked like when it was made, and expires. If the issue changes in the meantime, jira_write_apply refuses with JAM_WRITE_CONFLICT - re-plan against the new state rather than forcing the old one through.
|
|
25
|
+
|
|
26
26
|
A plan is a statement about what is possible right now, not a promise that it will happen. Nothing is written until jira_write_apply runs.`;
|
|
27
27
|
export function registerJiraWritePlan(server, deps) {
|
|
28
28
|
server.registerTool("jira_write_plan", {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ProjectConfig } from "../config/schema.js";
|
|
2
|
+
import type { CustomFieldKind, CustomFieldRequirements, CustomFieldUpdateInput, CustomFieldValueView, EditFieldMetadata, EditFieldOption } from "../domain/write.js";
|
|
3
|
+
/**
|
|
4
|
+
* What JAM will write to a custom field, and everything that has to be true
|
|
5
|
+
* first.
|
|
6
|
+
*
|
|
7
|
+
* Three separate permissions have to line up, and none of them implies
|
|
8
|
+
* another:
|
|
9
|
+
*
|
|
10
|
+
* 1. **The team said so.** The field's exact id is in the project's whitelist
|
|
11
|
+
* with `writable: true`. Being readable is not being writable - reading a
|
|
12
|
+
* field and letting an agent change it are different decisions, and a
|
|
13
|
+
* config written before JAM could write must not start granting writes
|
|
14
|
+
* because JAM learned how.
|
|
15
|
+
* 2. **Jira allows it here and now.** The field is on this issue's edit
|
|
16
|
+
* screen for this account, and Jira lists `set` among its operations.
|
|
17
|
+
* Asked, never modelled: applicability depends on project, issue type,
|
|
18
|
+
* field contexts, screens and permissions, and JAM does not carry a copy
|
|
19
|
+
* of any of that.
|
|
20
|
+
* 3. **JAM knows the shape.** The field's type is one of four families whose
|
|
21
|
+
* wire form JAM can produce from a plain value and compare afterwards.
|
|
22
|
+
* Anything else is refused rather than posted to find out.
|
|
23
|
+
*/
|
|
24
|
+
type WritableField = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Which configured field this selector names.
|
|
30
|
+
*
|
|
31
|
+
* The id is the identity; the name is an alias for people. Resolution is exact
|
|
32
|
+
* on either - no substring, no fuzz - because the alternative is an agent's
|
|
33
|
+
* approximate word choosing which field on somebody's board gets rewritten.
|
|
34
|
+
*
|
|
35
|
+
* Only `writable: true` entries are candidates, including for the refusal
|
|
36
|
+
* message: naming a read-only field as an alternative would suggest it is one
|
|
37
|
+
* selector away from being written.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveWritableField(config: ProjectConfig, requested: string): WritableField;
|
|
40
|
+
/**
|
|
41
|
+
* The field as Jira currently offers it on this issue, or a refusal.
|
|
42
|
+
*
|
|
43
|
+
* Absent from the edit metadata and present-but-not-settable are different
|
|
44
|
+
* situations with the same answer for the caller, so they share a code and
|
|
45
|
+
* differ in the detail: one means the field is not on this screen, the other
|
|
46
|
+
* that Jira will not let this account set it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertEditable(issueKey: string, field: WritableField, metadata: EditFieldMetadata[]): EditFieldMetadata;
|
|
49
|
+
/**
|
|
50
|
+
* Which of the four families this field belongs to, if any.
|
|
51
|
+
*
|
|
52
|
+
* Classified from Jira's own `schema`, which is the vocabulary Jira answers
|
|
53
|
+
* in. The implementation key (`schema.custom`) deliberately does not decide
|
|
54
|
+
* it: there are hundreds of them, they are app-specific, and a field's wire
|
|
55
|
+
* shape follows its type rather than its plugin.
|
|
56
|
+
*
|
|
57
|
+
* Anything unclassified is refused. Posting an unknown type to see what
|
|
58
|
+
* happens would use a Jira 400 as schema discovery, and on the occasions it
|
|
59
|
+
* did not 400 it would write something nobody described.
|
|
60
|
+
*/
|
|
61
|
+
export declare function classifyKind(field: EditFieldMetadata): CustomFieldKind;
|
|
62
|
+
/**
|
|
63
|
+
* The value, checked against the family and turned into what Jira expects.
|
|
64
|
+
*
|
|
65
|
+
* Types are never coerced. `"5"` is not `5`: a caller that meant a number can
|
|
66
|
+
* say so, and silently converting would make JAM's idea of the value differ
|
|
67
|
+
* from the caller's in exactly the cases where it matters.
|
|
68
|
+
*
|
|
69
|
+
* Nothing here clears a field. Empty strings, empty arrays and null are
|
|
70
|
+
* refused rather than treated as "unset" - removing a value is a different
|
|
71
|
+
* intent from setting one, and it is not in this version.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveCustomFieldValue(field: EditFieldMetadata, kind: CustomFieldKind, input: CustomFieldUpdateInput): {
|
|
74
|
+
jiraValue: unknown;
|
|
75
|
+
view: CustomFieldValueView;
|
|
76
|
+
resolvedOptions?: EditFieldOption[];
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Do this plan's premises still hold?
|
|
80
|
+
*
|
|
81
|
+
* Semantic, like the create schema check and for the same reason: comparing
|
|
82
|
+
* whole metadata documents would invalidate every outstanding plan whenever an
|
|
83
|
+
* unrelated field appeared on the screen. What is compared is what the plan
|
|
84
|
+
* actually rested on - the field is still settable, still the same family,
|
|
85
|
+
* still the same schema, and every option it chose is still offered under the
|
|
86
|
+
* same label.
|
|
87
|
+
*
|
|
88
|
+
* A renamed option is treated as a changed one. The id is the identity, but a
|
|
89
|
+
* label is what the plan showed a human before they agreed to it, and "Backend"
|
|
90
|
+
* becoming "Platform" is a different statement about the issue.
|
|
91
|
+
*/
|
|
92
|
+
export declare function assertCustomFieldUnchanged(issueKey: string, requirements: CustomFieldRequirements, metadata: EditFieldMetadata[]): void;
|
|
93
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { JamError } from "../domain/errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Which configured field this selector names.
|
|
4
|
+
*
|
|
5
|
+
* The id is the identity; the name is an alias for people. Resolution is exact
|
|
6
|
+
* on either - no substring, no fuzz - because the alternative is an agent's
|
|
7
|
+
* approximate word choosing which field on somebody's board gets rewritten.
|
|
8
|
+
*
|
|
9
|
+
* Only `writable: true` entries are candidates, including for the refusal
|
|
10
|
+
* message: naming a read-only field as an alternative would suggest it is one
|
|
11
|
+
* selector away from being written.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveWritableField(config, requested) {
|
|
14
|
+
const wanted = requested.trim();
|
|
15
|
+
if (wanted.length === 0) {
|
|
16
|
+
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "custom-field.update needs a non-empty `input.field`.", { operation: "custom-field.update" });
|
|
17
|
+
}
|
|
18
|
+
const writable = config.customFields.filter((f) => f.writable);
|
|
19
|
+
const match = writable.find((f) => f.id.toLowerCase() === wanted.toLowerCase()) ??
|
|
20
|
+
writable.find((f) => f.name.trim().toLowerCase() === wanted.toLowerCase());
|
|
21
|
+
if (!match) {
|
|
22
|
+
throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", writable.length === 0
|
|
23
|
+
? `No custom field in this project is writable. A team opts one in by adding \`writable: true\` to its entry in .jira-agent/project.yaml; being readable does not make a field writable.`
|
|
24
|
+
: `"${requested}" is not a writable custom field in this project. JAM writes only the exact ids a team has opted in.`, {
|
|
25
|
+
requested,
|
|
26
|
+
writableCustomFields: writable.map((f) => ({ id: f.id, name: f.name })),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return { id: match.id, name: match.name };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The field as Jira currently offers it on this issue, or a refusal.
|
|
33
|
+
*
|
|
34
|
+
* Absent from the edit metadata and present-but-not-settable are different
|
|
35
|
+
* situations with the same answer for the caller, so they share a code and
|
|
36
|
+
* differ in the detail: one means the field is not on this screen, the other
|
|
37
|
+
* that Jira will not let this account set it.
|
|
38
|
+
*/
|
|
39
|
+
export function assertEditable(issueKey, field, metadata) {
|
|
40
|
+
const found = metadata.find((f) => f.id === field.id);
|
|
41
|
+
if (!found) {
|
|
42
|
+
throw new JamError("JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", `Jira does not offer ${field.name} (${field.id}) on ${issueKey}'s edit screen for this account. The field may not apply to this project or issue type, or this account may not be able to edit it.`, { issueKey, fieldId: field.id, fieldName: field.name, reason: "NOT_ON_EDIT_SCREEN" });
|
|
43
|
+
}
|
|
44
|
+
if (!found.operations.includes("set")) {
|
|
45
|
+
throw new JamError("JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", `Jira lists ${field.name} (${field.id}) on ${issueKey} but does not offer "set" for it${found.operations.length > 0 ? ` - only ${found.operations.join(", ")}` : ""}. JAM only sets a value; it does not add to or remove from one.`, {
|
|
46
|
+
issueKey,
|
|
47
|
+
fieldId: field.id,
|
|
48
|
+
fieldName: field.name,
|
|
49
|
+
operations: found.operations,
|
|
50
|
+
reason: "SET_NOT_OFFERED",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Which of the four families this field belongs to, if any.
|
|
57
|
+
*
|
|
58
|
+
* Classified from Jira's own `schema`, which is the vocabulary Jira answers
|
|
59
|
+
* in. The implementation key (`schema.custom`) deliberately does not decide
|
|
60
|
+
* it: there are hundreds of them, they are app-specific, and a field's wire
|
|
61
|
+
* shape follows its type rather than its plugin.
|
|
62
|
+
*
|
|
63
|
+
* Anything unclassified is refused. Posting an unknown type to see what
|
|
64
|
+
* happens would use a Jira 400 as schema discovery, and on the occasions it
|
|
65
|
+
* did not 400 it would write something nobody described.
|
|
66
|
+
*/
|
|
67
|
+
export function classifyKind(field) {
|
|
68
|
+
const { type, items } = field.schema;
|
|
69
|
+
if (type === "string" && !items)
|
|
70
|
+
return "text";
|
|
71
|
+
if (type === "number" && !items)
|
|
72
|
+
return "number";
|
|
73
|
+
if (type === "option" && !items)
|
|
74
|
+
return "single-option";
|
|
75
|
+
if (type === "array" && items === "option")
|
|
76
|
+
return "multi-option";
|
|
77
|
+
throw new JamError("JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", `${field.name} (${field.id}) is a ${describeType(field)} field, and JAM does not know how to write one safely yet. Supported: single-line text, number, single-select and multi-select.`, {
|
|
78
|
+
fieldId: field.id,
|
|
79
|
+
fieldName: field.name,
|
|
80
|
+
schema: field.schema,
|
|
81
|
+
supported: ["text", "number", "single-option", "multi-option"],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function describeType(field) {
|
|
85
|
+
const { type, items } = field.schema;
|
|
86
|
+
return items ? `${type} of ${items}` : type;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The value, checked against the family and turned into what Jira expects.
|
|
90
|
+
*
|
|
91
|
+
* Types are never coerced. `"5"` is not `5`: a caller that meant a number can
|
|
92
|
+
* say so, and silently converting would make JAM's idea of the value differ
|
|
93
|
+
* from the caller's in exactly the cases where it matters.
|
|
94
|
+
*
|
|
95
|
+
* Nothing here clears a field. Empty strings, empty arrays and null are
|
|
96
|
+
* refused rather than treated as "unset" - removing a value is a different
|
|
97
|
+
* intent from setting one, and it is not in this version.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveCustomFieldValue(field, kind, input) {
|
|
100
|
+
const { value } = input;
|
|
101
|
+
const named = { id: field.id, name: field.name };
|
|
102
|
+
switch (kind) {
|
|
103
|
+
case "text": {
|
|
104
|
+
if (typeof value !== "string")
|
|
105
|
+
throw wrongType(field, kind, value);
|
|
106
|
+
const text = value.trim();
|
|
107
|
+
if (text.length === 0)
|
|
108
|
+
throw refuseClear(field);
|
|
109
|
+
return { jiraValue: text, view: { ...named, value: text } };
|
|
110
|
+
}
|
|
111
|
+
case "number": {
|
|
112
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
113
|
+
throw wrongType(field, kind, value);
|
|
114
|
+
return { jiraValue: value, view: { ...named, value } };
|
|
115
|
+
}
|
|
116
|
+
case "single-option": {
|
|
117
|
+
if (typeof value !== "string")
|
|
118
|
+
throw wrongType(field, kind, value);
|
|
119
|
+
const option = resolveOption(field, value);
|
|
120
|
+
// Jira takes the option by id. The label is what a person reads, and two
|
|
121
|
+
// options could carry the same one.
|
|
122
|
+
return {
|
|
123
|
+
jiraValue: { id: option.id },
|
|
124
|
+
view: { ...named, value: option },
|
|
125
|
+
resolvedOptions: [option],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
case "multi-option": {
|
|
129
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
130
|
+
throw wrongType(field, kind, value);
|
|
131
|
+
}
|
|
132
|
+
if (value.length === 0)
|
|
133
|
+
throw refuseClear(field);
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
for (const raw of value) {
|
|
136
|
+
const key = raw.trim().toLowerCase();
|
|
137
|
+
if (seen.has(key)) {
|
|
138
|
+
throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `"${raw}" appears more than once in the value for ${field.name}. JAM does not quietly drop the repeat - say each option once.`, { fieldId: field.id, repeated: raw });
|
|
139
|
+
}
|
|
140
|
+
seen.add(key);
|
|
141
|
+
}
|
|
142
|
+
// Every option resolves, or none is written. A partly-applied selection
|
|
143
|
+
// is a selection nobody asked for.
|
|
144
|
+
const options = value.map((raw) => resolveOption(field, raw));
|
|
145
|
+
return {
|
|
146
|
+
jiraValue: options.map((o) => ({ id: o.id })),
|
|
147
|
+
view: { ...named, value: options },
|
|
148
|
+
resolvedOptions: options,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Which option Jira offers under this name, if exactly one does.
|
|
155
|
+
*
|
|
156
|
+
* An option id wins outright, then an exact label ignoring case and space.
|
|
157
|
+
* Nothing partial: Jira's option lists are short and a caller can name one
|
|
158
|
+
* exactly, so a near miss is a question rather than a guess.
|
|
159
|
+
*/
|
|
160
|
+
function resolveOption(field, requested) {
|
|
161
|
+
const allowed = field.allowedValues;
|
|
162
|
+
if (!allowed) {
|
|
163
|
+
throw new JamError("JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", `${field.name} (${field.id}) is a select field, but Jira did not say which options it offers, so JAM cannot resolve "${requested}" to one.`, { fieldId: field.id, fieldName: field.name, schema: field.schema });
|
|
164
|
+
}
|
|
165
|
+
const wanted = requested.trim();
|
|
166
|
+
const byId = allowed.filter((o) => o.id === wanted);
|
|
167
|
+
const matches = byId.length > 0
|
|
168
|
+
? byId
|
|
169
|
+
: allowed.filter((o) => o.label.trim().toLowerCase() === wanted.toLowerCase());
|
|
170
|
+
if (matches.length === 0) {
|
|
171
|
+
throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", allowed.length === 0
|
|
172
|
+
? `Jira offers no options for ${field.name} on this issue, so "${requested}" cannot be set.`
|
|
173
|
+
: `"${requested}" is not an option Jira offers for ${field.name}. Allowed: ${allowed.map((o) => o.label).join(", ")}.`, { fieldId: field.id, requested, allowed });
|
|
174
|
+
}
|
|
175
|
+
if (matches.length > 1) {
|
|
176
|
+
throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `"${requested}" matches ${matches.length} options for ${field.name}. Pass the option id of the one you mean.`, { fieldId: field.id, requested, candidates: matches });
|
|
177
|
+
}
|
|
178
|
+
return matches[0];
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Do this plan's premises still hold?
|
|
182
|
+
*
|
|
183
|
+
* Semantic, like the create schema check and for the same reason: comparing
|
|
184
|
+
* whole metadata documents would invalidate every outstanding plan whenever an
|
|
185
|
+
* unrelated field appeared on the screen. What is compared is what the plan
|
|
186
|
+
* actually rested on - the field is still settable, still the same family,
|
|
187
|
+
* still the same schema, and every option it chose is still offered under the
|
|
188
|
+
* same label.
|
|
189
|
+
*
|
|
190
|
+
* A renamed option is treated as a changed one. The id is the identity, but a
|
|
191
|
+
* label is what the plan showed a human before they agreed to it, and "Backend"
|
|
192
|
+
* becoming "Platform" is a different statement about the issue.
|
|
193
|
+
*/
|
|
194
|
+
export function assertCustomFieldUnchanged(issueKey, requirements, metadata) {
|
|
195
|
+
const field = metadata.find((f) => f.id === requirements.fieldId);
|
|
196
|
+
if (!field) {
|
|
197
|
+
throw schemaChanged(`${requirements.fieldName} (${requirements.fieldId}) is no longer on ${issueKey}'s edit screen for this account.`, { issueKey, fieldId: requirements.fieldId });
|
|
198
|
+
}
|
|
199
|
+
if (!field.operations.includes("set")) {
|
|
200
|
+
throw schemaChanged(`Jira no longer offers "set" for ${requirements.fieldName} on ${issueKey}.`, { issueKey, fieldId: field.id, operations: field.operations });
|
|
201
|
+
}
|
|
202
|
+
if (field.schema.type !== requirements.schema.type ||
|
|
203
|
+
field.schema.items !== requirements.schema.items) {
|
|
204
|
+
throw schemaChanged(`${requirements.fieldName} is no longer a ${requirements.kind} field.`, { issueKey, fieldId: field.id, planned: requirements.schema, current: field.schema });
|
|
205
|
+
}
|
|
206
|
+
for (const planned of requirements.resolvedOptions ?? []) {
|
|
207
|
+
const current = field.allowedValues?.find((o) => o.id === planned.id);
|
|
208
|
+
if (!current) {
|
|
209
|
+
throw schemaChanged(`Option "${planned.label}" is no longer offered for ${requirements.fieldName}.`, { issueKey, fieldId: field.id, option: planned });
|
|
210
|
+
}
|
|
211
|
+
if (current.label !== planned.label) {
|
|
212
|
+
throw schemaChanged(`Option "${planned.label}" has been renamed to "${current.label}", so this plan no longer describes the change it showed.`, { issueKey, fieldId: field.id, planned, current });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function schemaChanged(what, details) {
|
|
217
|
+
return new JamError("JAM_WRITE_SCHEMA_CHANGED", `${what} This plan was built on the field's configuration as it was, so it no longer describes a change JAM can make. Nothing was written - plan again.`, details);
|
|
218
|
+
}
|
|
219
|
+
function wrongType(field, kind, value) {
|
|
220
|
+
const wanted = {
|
|
221
|
+
text: "a string",
|
|
222
|
+
number: "a number",
|
|
223
|
+
"single-option": "a string naming one option",
|
|
224
|
+
"multi-option": "an array of strings naming options",
|
|
225
|
+
}[kind];
|
|
226
|
+
return new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `${field.name} (${field.id}) is a ${kind} field and needs ${wanted}. JAM does not convert between types - "5" and 5 are different values, and guessing which was meant is not JAM's to do.`, { fieldId: field.id, kind, received: typeof value });
|
|
227
|
+
}
|
|
228
|
+
function refuseClear(field) {
|
|
229
|
+
return new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `custom-field.update sets a value; it does not clear one. ${field.name} cannot be set to an empty value in this version.`, { fieldId: field.id, reason: "CLEAR_NOT_SUPPORTED" });
|
|
230
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EditFieldMetadata } from "../domain/write.js";
|
|
2
|
+
/**
|
|
3
|
+
* What Jira will let this account change on one issue, right now.
|
|
4
|
+
*
|
|
5
|
+
* `GET /rest/api/3/issue/{key}/editmeta` is the authority, and it is asked
|
|
6
|
+
* rather than reconstructed. A custom field's applicability depends on the
|
|
7
|
+
* project, the issue type, the field's contexts, the screen it is on and the
|
|
8
|
+
* permissions of whoever is asking - JAM does not carry a copy of that model,
|
|
9
|
+
* and the field-context APIs that would let it try need administrator rights
|
|
10
|
+
* most tokens do not have. So the question is put to Jira in the form it can
|
|
11
|
+
* answer exactly: on this issue, for this account, what is editable and how.
|
|
12
|
+
*
|
|
13
|
+
* The same shape as the other read-shaped ports, for the same reasons: it
|
|
14
|
+
* mutates nothing, so it does not belong behind the write port's no-retry
|
|
15
|
+
* contract, and it answers a question about a configuration rather than about
|
|
16
|
+
* an issue, so the read port's completeness semantics would mean nothing here.
|
|
17
|
+
*
|
|
18
|
+
* It does not retry. Its answer decides a mutation.
|
|
19
|
+
*/
|
|
20
|
+
export interface JiraEditMetadataPort {
|
|
21
|
+
getEditableFields(issueKey: string): Promise<EditFieldMetadata[]>;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"jira",
|
|
7
|
-
"mcp",
|
|
8
|
-
"model-context-protocol",
|
|
9
|
-
"claude-code",
|
|
10
|
-
"codex",
|
|
11
|
-
"jira-api"
|
|
12
|
-
],
|
|
13
|
-
"homepage": "https://github.com/colosair/jam#readme",
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/colosair/jam/issues"
|
|
16
|
-
},
|
|
17
|
-
"repository": {
|
|
18
|
-
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/colosair/jam.git",
|
|
20
|
-
"directory": "packages/server"
|
|
21
|
-
},
|
|
22
|
-
"author": "colosair (https://github.com/colosair)",
|
|
23
|
-
"type": "module",
|
|
24
|
-
"bin": {
|
|
25
|
-
"jam-server": "dist/index.js"
|
|
26
|
-
},
|
|
27
|
-
"main": "dist/index.js",
|
|
28
|
-
"types": "dist/index.d.ts",
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=20"
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"dist",
|
|
34
|
-
"!dist/**/*.js.map",
|
|
35
|
-
"README.md"
|
|
36
|
-
],
|
|
37
|
-
"scripts": {
|
|
38
|
-
"build": "tsc",
|
|
39
|
-
"dev": "tsc --watch",
|
|
40
|
-
"test": "vitest run",
|
|
41
|
-
"test:watch": "vitest"
|
|
42
|
-
},
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"@jam-mcp/launcher": "1.3.
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
|
-
"yaml": "^2.9.0",
|
|
47
|
-
"zod": "^4.4.3"
|
|
48
|
-
},
|
|
49
|
-
"devDependencies": {
|
|
50
|
-
"@types/node": "^24.0.0",
|
|
51
|
-
"typescript": "^5.9.0",
|
|
52
|
-
"vitest": "^4.1.11"
|
|
53
|
-
},
|
|
54
|
-
"license": "MIT",
|
|
55
|
-
"exports": {
|
|
56
|
-
".": {
|
|
57
|
-
"types": "./dist/index.d.ts",
|
|
58
|
-
"default": "./dist/index.js"
|
|
59
|
-
},
|
|
60
|
-
"./cli-entry": {
|
|
61
|
-
"types": "./dist/cli-entry.d.ts",
|
|
62
|
-
"default": "./dist/cli-entry.js"
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
"publishConfig": {
|
|
66
|
-
"access": "public",
|
|
67
|
-
"registry": "https://registry.npmjs.org/"
|
|
68
|
-
}
|
|
69
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@jam-mcp/server",
|
|
3
|
+
"version": "1.3.2",
|
|
4
|
+
"description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"jira",
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"jira-api"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/colosair/jam#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/colosair/jam/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/colosair/jam.git",
|
|
20
|
+
"directory": "packages/server"
|
|
21
|
+
},
|
|
22
|
+
"author": "colosair (https://github.com/colosair)",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"jam-server": "dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"types": "dist/index.d.ts",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"!dist/**/*.js.map",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"dev": "tsc --watch",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@jam-mcp/launcher": "1.3.2",
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
|
+
"yaml": "^2.9.0",
|
|
47
|
+
"zod": "^4.4.3"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^24.0.0",
|
|
51
|
+
"typescript": "^5.9.0",
|
|
52
|
+
"vitest": "^4.1.11"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"exports": {
|
|
56
|
+
".": {
|
|
57
|
+
"types": "./dist/index.d.ts",
|
|
58
|
+
"default": "./dist/index.js"
|
|
59
|
+
},
|
|
60
|
+
"./cli-entry": {
|
|
61
|
+
"types": "./dist/cli-entry.d.ts",
|
|
62
|
+
"default": "./dist/cli-entry.js"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public",
|
|
67
|
+
"registry": "https://registry.npmjs.org/"
|
|
68
|
+
}
|
|
69
|
+
}
|