@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/adapters/cache/noop-cache.d.ts +7 -0
- package/dist/adapters/cache/noop-cache.js +12 -0
- package/dist/adapters/credentials/composite.d.ts +30 -0
- package/dist/adapters/credentials/composite.js +79 -0
- package/dist/adapters/credentials/process-env.d.ts +13 -0
- package/dist/adapters/credentials/process-env.js +17 -0
- package/dist/adapters/credentials/secret-store.d.ts +69 -0
- package/dist/adapters/credentials/secret-store.js +259 -0
- package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
- package/dist/adapters/credentials/windows-user-env.js +51 -0
- package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
- package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
- package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
- package/dist/adapters/jira-cloud/jira-client.js +137 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
- package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
- package/dist/adapters/jira-cloud/mapper.js +143 -0
- package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
- package/dist/adapters/telemetry/console-telemetry.js +31 -0
- package/dist/application/get-full-issue-context.d.ts +18 -0
- package/dist/application/get-full-issue-context.js +120 -0
- package/dist/application/get-issue-context.d.ts +19 -0
- package/dist/application/get-issue-context.js +79 -0
- package/dist/application/search-issues.d.ts +22 -0
- package/dist/application/search-issues.js +93 -0
- package/dist/bootstrap/boot-health-gate.d.ts +25 -0
- package/dist/bootstrap/boot-health-gate.js +137 -0
- package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
- package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
- package/dist/bootstrap/host-mcp.d.ts +54 -0
- package/dist/bootstrap/host-mcp.js +98 -0
- package/dist/bootstrap/jira-projects.d.ts +16 -0
- package/dist/bootstrap/jira-projects.js +24 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
- package/dist/bootstrap/mcp-config-merger.js +119 -0
- package/dist/bootstrap/migration-target.d.ts +60 -0
- package/dist/bootstrap/migration-target.js +80 -0
- package/dist/bootstrap/project-bindings.d.ts +48 -0
- package/dist/bootstrap/project-bindings.js +112 -0
- package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
- package/dist/bootstrap/project-config-bootstrapper.js +69 -0
- package/dist/bootstrap/project-config-resolver.d.ts +53 -0
- package/dist/bootstrap/project-config-resolver.js +59 -0
- package/dist/bootstrap/project-root-resolver.d.ts +22 -0
- package/dist/bootstrap/project-root-resolver.js +43 -0
- package/dist/bootstrap/setup-apply.d.ts +31 -0
- package/dist/bootstrap/setup-apply.js +61 -0
- package/dist/bootstrap/setup-plan.d.ts +102 -0
- package/dist/bootstrap/setup-plan.js +224 -0
- package/dist/bootstrap/setup-state.d.ts +73 -0
- package/dist/bootstrap/setup-state.js +89 -0
- package/dist/bootstrap/workspace-identity.d.ts +52 -0
- package/dist/bootstrap/workspace-identity.js +114 -0
- package/dist/cli/agent-api.d.ts +63 -0
- package/dist/cli/agent-api.js +149 -0
- package/dist/cli/auth.d.ts +39 -0
- package/dist/cli/auth.js +201 -0
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +31 -0
- package/dist/cli/runtime.d.ts +16 -0
- package/dist/cli/runtime.js +97 -0
- package/dist/cli/serve.d.ts +7 -0
- package/dist/cli/serve.js +23 -0
- package/dist/cli/setup-wizard.d.ts +37 -0
- package/dist/cli/setup-wizard.js +338 -0
- package/dist/cli/setup.d.ts +26 -0
- package/dist/cli/setup.js +214 -0
- package/dist/cli/ui.d.ts +118 -0
- package/dist/cli/ui.js +338 -0
- package/dist/cli-entry.d.ts +7 -0
- package/dist/cli-entry.js +124 -0
- package/dist/config/load-config.d.ts +18 -0
- package/dist/config/load-config.js +48 -0
- package/dist/config/schema.d.ts +36 -0
- package/dist/config/schema.js +74 -0
- package/dist/deps.d.ts +50 -0
- package/dist/deps.js +39 -0
- package/dist/domain/completeness.d.ts +63 -0
- package/dist/domain/completeness.js +20 -0
- package/dist/domain/context.d.ts +38 -0
- package/dist/domain/context.js +1 -0
- package/dist/domain/errors.d.ts +27 -0
- package/dist/domain/errors.js +49 -0
- package/dist/domain/issue.d.ts +35 -0
- package/dist/domain/issue.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/mcp/create-server.d.ts +9 -0
- package/dist/mcp/create-server.js +32 -0
- package/dist/mcp/tool-result.d.ts +10 -0
- package/dist/mcp/tool-result.js +33 -0
- package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-context.tool.js +25 -0
- package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-full.tool.js +27 -0
- package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-search.tool.js +31 -0
- package/dist/policy/completeness-policy.d.ts +19 -0
- package/dist/policy/completeness-policy.js +34 -0
- package/dist/policy/consistency-policy.d.ts +13 -0
- package/dist/policy/consistency-policy.js +6 -0
- package/dist/policy/field-policy.d.ts +11 -0
- package/dist/policy/field-policy.js +26 -0
- package/dist/policy/output-budget-policy.d.ts +23 -0
- package/dist/policy/output-budget-policy.js +96 -0
- package/dist/policy/pagination-policy.d.ts +15 -0
- package/dist/policy/pagination-policy.js +13 -0
- package/dist/ports/cache.port.d.ts +5 -0
- package/dist/ports/cache.port.js +1 -0
- package/dist/ports/credentials.port.d.ts +27 -0
- package/dist/ports/credentials.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +73 -0
- package/dist/ports/jira-read.port.js +1 -0
- package/dist/ports/jira-write.port.d.ts +12 -0
- package/dist/ports/jira-write.port.js +1 -0
- package/dist/ports/telemetry.port.d.ts +13 -0
- package/dist/ports/telemetry.port.js +1 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +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.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
Normally you don't run this package directly — your coding agent launches
|
|
42
|
+
[`@jam-mcp/launcher`](https://www.npmjs.com/package/@jam-mcp/launcher), which
|
|
43
|
+
resolves which JAM build this machine runs and dispatches here. Going through
|
|
44
|
+
the launcher is what keeps a committed `.mcp.json` free of machine-specific
|
|
45
|
+
paths.
|
|
46
|
+
|
|
47
|
+
The `jam` CLI is the same binary:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
jam serve Run the MCP server over stdio
|
|
51
|
+
jam doctor Diagnose config, credentials and Jira connectivity
|
|
52
|
+
jam setup Wire up JAM and verify (personal by default; --shared for the team)
|
|
53
|
+
jam auth login Store Jira credentials in this user's OS secret store
|
|
54
|
+
jam runtime Show or change which JAM build this machine runs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Credentials come from the process environment or this user's OS secret store —
|
|
58
|
+
never from a repository file — and never appear in logs, telemetry, or tool
|
|
59
|
+
results. `stdout` is reserved for the MCP protocol and for JSON output;
|
|
60
|
+
diagnostics go to `stderr`.
|
|
61
|
+
|
|
62
|
+
## More
|
|
63
|
+
|
|
64
|
+
- [Repository README](https://github.com/colosair/jam#readme)
|
|
65
|
+
- [JAM design of record](https://github.com/colosair/jam/blob/main/docs/architecture/jira-agent-mcp-design.md)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CachePort } from "../../ports/cache.port.js";
|
|
2
|
+
/** First release ships without caching. Jira stays the single source of truth. */
|
|
3
|
+
export declare class NoopCache implements CachePort {
|
|
4
|
+
get<T>(): Promise<T | undefined>;
|
|
5
|
+
set(): Promise<void>;
|
|
6
|
+
invalidate(): Promise<void>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CredentialDescription, CredentialPort, CredentialSource, JiraCredentials } from "../../ports/credentials.port.js";
|
|
2
|
+
import { type CredentialValueSource } from "./process-env.js";
|
|
3
|
+
type NamedSource = {
|
|
4
|
+
name: Exclude<CredentialSource, "mixed" | "none">;
|
|
5
|
+
source: CredentialValueSource;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Merges credential values field-by-field across sources, in priority order:
|
|
9
|
+
* the current process's own environment, then this user's OS secret store,
|
|
10
|
+
* then the Windows User environment.
|
|
11
|
+
*
|
|
12
|
+
* process.env stays first so a per-session override still behaves as expected,
|
|
13
|
+
* and so CI keeps working unchanged. The secret store comes next because it is
|
|
14
|
+
* the only source an editor launched from a Dock or Start menu can reach - such
|
|
15
|
+
* a process never sourced a shell profile. The Windows User environment stays
|
|
16
|
+
* last: a team member who only ever `setx`'d their token still boots without a
|
|
17
|
+
* fresh terminal.
|
|
18
|
+
*
|
|
19
|
+
* Merging is per field, so a single exported variable can shadow one field of a
|
|
20
|
+
* stored credential and leave the rest - `describe()` reports that as "mixed".
|
|
21
|
+
*/
|
|
22
|
+
export declare class CompositeCredentialProvider implements CredentialPort {
|
|
23
|
+
private readonly sources;
|
|
24
|
+
private cached?;
|
|
25
|
+
constructor(sources?: NamedSource[]);
|
|
26
|
+
private resolve;
|
|
27
|
+
load(): JiraCredentials;
|
|
28
|
+
describe(): CredentialDescription;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { JamError } from "../../domain/errors.js";
|
|
2
|
+
import { CREDENTIAL_ENV_KEYS, ProcessEnvCredentialSource, } from "./process-env.js";
|
|
3
|
+
import { SecretStoreCredentialSource, resolveSecretStore } from "./secret-store.js";
|
|
4
|
+
import { WindowsUserEnvCredentialSource } from "./windows-user-env.js";
|
|
5
|
+
const FIELD_BY_KEY = {
|
|
6
|
+
JIRA_BASE_URL: "baseUrl",
|
|
7
|
+
JIRA_EMAIL: "email",
|
|
8
|
+
JIRA_API_TOKEN: "apiToken",
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Merges credential values field-by-field across sources, in priority order:
|
|
12
|
+
* the current process's own environment, then this user's OS secret store,
|
|
13
|
+
* then the Windows User environment.
|
|
14
|
+
*
|
|
15
|
+
* process.env stays first so a per-session override still behaves as expected,
|
|
16
|
+
* and so CI keeps working unchanged. The secret store comes next because it is
|
|
17
|
+
* the only source an editor launched from a Dock or Start menu can reach - such
|
|
18
|
+
* a process never sourced a shell profile. The Windows User environment stays
|
|
19
|
+
* last: a team member who only ever `setx`'d their token still boots without a
|
|
20
|
+
* fresh terminal.
|
|
21
|
+
*
|
|
22
|
+
* Merging is per field, so a single exported variable can shadow one field of a
|
|
23
|
+
* stored credential and leave the rest - `describe()` reports that as "mixed".
|
|
24
|
+
*/
|
|
25
|
+
export class CompositeCredentialProvider {
|
|
26
|
+
sources;
|
|
27
|
+
cached;
|
|
28
|
+
constructor(sources = [
|
|
29
|
+
{ name: "process", source: new ProcessEnvCredentialSource() },
|
|
30
|
+
{ name: "secret-store", source: new SecretStoreCredentialSource(resolveSecretStore()) },
|
|
31
|
+
{ name: "user-env", source: new WindowsUserEnvCredentialSource() },
|
|
32
|
+
]) {
|
|
33
|
+
this.sources = sources;
|
|
34
|
+
}
|
|
35
|
+
resolve() {
|
|
36
|
+
if (this.cached)
|
|
37
|
+
return this.cached;
|
|
38
|
+
const values = {};
|
|
39
|
+
const sourceByKey = {};
|
|
40
|
+
for (const { name, source } of this.sources) {
|
|
41
|
+
const read = source.read();
|
|
42
|
+
for (const key of CREDENTIAL_ENV_KEYS) {
|
|
43
|
+
if (values[key] === undefined && read[key]) {
|
|
44
|
+
values[key] = read[key];
|
|
45
|
+
sourceByKey[key] = name;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this.cached = { values, sourceByKey };
|
|
50
|
+
return this.cached;
|
|
51
|
+
}
|
|
52
|
+
load() {
|
|
53
|
+
const { values } = this.resolve();
|
|
54
|
+
const missing = CREDENTIAL_ENV_KEYS.filter((k) => !values[k]);
|
|
55
|
+
if (missing.length > 0) {
|
|
56
|
+
throw new JamError("CONFIG_INVALID", `Missing Jira credentials: ${missing.join(", ")} (checked the process environment, this user's OS secret store, then the Windows User environment). Run \`jam auth login\`.`, { missing });
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
baseUrl: values.JIRA_BASE_URL.replace(/\/+$/, ""),
|
|
60
|
+
email: values.JIRA_EMAIL,
|
|
61
|
+
apiToken: values.JIRA_API_TOKEN,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
describe() {
|
|
65
|
+
const { values, sourceByKey } = this.resolve();
|
|
66
|
+
const used = new Set(Object.values(sourceByKey));
|
|
67
|
+
let source = "none";
|
|
68
|
+
if (used.size === 1)
|
|
69
|
+
source = [...used][0];
|
|
70
|
+
else if (used.size > 1)
|
|
71
|
+
source = "mixed";
|
|
72
|
+
const description = { hasToken: Boolean(values.JIRA_API_TOKEN), source };
|
|
73
|
+
if (values.JIRA_BASE_URL)
|
|
74
|
+
description.baseUrl = values.JIRA_BASE_URL;
|
|
75
|
+
if (values.JIRA_EMAIL)
|
|
76
|
+
description.email = values.JIRA_EMAIL;
|
|
77
|
+
return description;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const CREDENTIAL_ENV_KEYS: readonly ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"];
|
|
2
|
+
export type CredentialEnvKey = (typeof CREDENTIAL_ENV_KEYS)[number];
|
|
3
|
+
export type RawCredentialValues = Partial<Record<CredentialEnvKey, string>>;
|
|
4
|
+
/** A named source of raw credential values, keyed by env var name. */
|
|
5
|
+
export interface CredentialValueSource {
|
|
6
|
+
read(): RawCredentialValues;
|
|
7
|
+
}
|
|
8
|
+
/** Reads the current process's own environment. Always tried first. */
|
|
9
|
+
export declare class ProcessEnvCredentialSource implements CredentialValueSource {
|
|
10
|
+
private readonly env;
|
|
11
|
+
constructor(env?: NodeJS.ProcessEnv);
|
|
12
|
+
read(): RawCredentialValues;
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const CREDENTIAL_ENV_KEYS = ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"];
|
|
2
|
+
/** Reads the current process's own environment. Always tried first. */
|
|
3
|
+
export class ProcessEnvCredentialSource {
|
|
4
|
+
env;
|
|
5
|
+
constructor(env = process.env) {
|
|
6
|
+
this.env = env;
|
|
7
|
+
}
|
|
8
|
+
read() {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const key of CREDENTIAL_ENV_KEYS) {
|
|
11
|
+
const value = this.env[key]?.trim();
|
|
12
|
+
if (value)
|
|
13
|
+
out[key] = value;
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type CredentialValueSource, type RawCredentialValues } from "./process-env.js";
|
|
2
|
+
/**
|
|
3
|
+
* Credentials held by the operating system for this user, rather than by a
|
|
4
|
+
* shell profile.
|
|
5
|
+
*
|
|
6
|
+
* This is what makes JAM work in an editor launched from a Dock or Start menu.
|
|
7
|
+
* Such an editor never sourced a shell profile, so the MCP child it spawns
|
|
8
|
+
* inherits no `JIRA_*` at all - but it does run as the user, so it can read
|
|
9
|
+
* what the OS is holding for that user.
|
|
10
|
+
*
|
|
11
|
+
* The stored value never appears in a log, an error, or a tool result. It is
|
|
12
|
+
* read into memory, handed to the credential chain, and nothing else.
|
|
13
|
+
*/
|
|
14
|
+
export type StoredCredentials = {
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
email: string;
|
|
17
|
+
apiToken: string;
|
|
18
|
+
};
|
|
19
|
+
export type RunResult = {
|
|
20
|
+
status: number | null;
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
error?: NodeJS.ErrnoException;
|
|
24
|
+
};
|
|
25
|
+
/** Injected by tests so the suite never touches a real keychain. */
|
|
26
|
+
export type RunFn = (command: string, args: string[], input?: string) => RunResult;
|
|
27
|
+
export interface SecretStore {
|
|
28
|
+
/** Shown by `jam auth login`. Names the mechanism, never a value. */
|
|
29
|
+
readonly label: string;
|
|
30
|
+
/** Missing or unreadable resolves to undefined - reading never throws. */
|
|
31
|
+
read(): StoredCredentials | undefined;
|
|
32
|
+
/** Throws on failure: the user just asked for this and must not be told it worked. */
|
|
33
|
+
write(values: StoredCredentials): void;
|
|
34
|
+
/** Succeeds when there was nothing to remove. */
|
|
35
|
+
clear(): void;
|
|
36
|
+
}
|
|
37
|
+
/** Raised when the platform's backend is absent, as opposed to empty. */
|
|
38
|
+
export declare class SecretStoreUnavailableError extends Error {
|
|
39
|
+
readonly remedy: string;
|
|
40
|
+
constructor(message: string, remedy: string);
|
|
41
|
+
}
|
|
42
|
+
export declare function secretStoreDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The store for this system, or undefined when there is none that works.
|
|
45
|
+
*
|
|
46
|
+
* Being on Linux is not the same as having a secret store: a headless server or
|
|
47
|
+
* a container routinely has no libsecret and no session keyring. So the backend
|
|
48
|
+
* is probed, not assumed from `process.platform`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveSecretStore(run?: RunFn, env?: NodeJS.ProcessEnv): SecretStore | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* The credential chain's view of the store.
|
|
53
|
+
*
|
|
54
|
+
* Fail-soft by design, like the Windows registry source: a machine with no
|
|
55
|
+
* store, or an empty one, is a normal state that must not turn every command
|
|
56
|
+
* into an error. `jam auth login` is where a failure gets reported, because
|
|
57
|
+
* there the user asked for something specific.
|
|
58
|
+
*/
|
|
59
|
+
export declare class SecretStoreCredentialSource implements CredentialValueSource {
|
|
60
|
+
private readonly store;
|
|
61
|
+
/**
|
|
62
|
+
* The store is required, with no default that would resolve a real one.
|
|
63
|
+
* A parameter default cannot tell `undefined` meaning "this system has no
|
|
64
|
+
* store" from `undefined` meaning "not supplied" - and getting that wrong
|
|
65
|
+
* silently reaches the user's real keychain, including from a test.
|
|
66
|
+
*/
|
|
67
|
+
constructor(store: SecretStore | undefined);
|
|
68
|
+
read(): RawCredentialValues;
|
|
69
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, userInfo } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CREDENTIAL_ENV_KEYS, } from "./process-env.js";
|
|
6
|
+
/** Raised when the platform's backend is absent, as opposed to empty. */
|
|
7
|
+
export class SecretStoreUnavailableError extends Error {
|
|
8
|
+
remedy;
|
|
9
|
+
constructor(message, remedy) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "SecretStoreUnavailableError";
|
|
12
|
+
this.remedy = remedy;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Derived from constants, never from user input, except the account name. */
|
|
16
|
+
const SERVICE = "jam-mcp";
|
|
17
|
+
/**
|
|
18
|
+
* Escape hatch for isolated test sandboxes.
|
|
19
|
+
*
|
|
20
|
+
* An OS secret store is per-user, not per-HOME, so a sandbox that repoints HOME
|
|
21
|
+
* still reads the developer's real keychain - the same hole the Windows
|
|
22
|
+
* registry source has always had. This is not a user-facing feature: it exists
|
|
23
|
+
* so `npm run smoke` can be offline and deterministic, and `jam auth login`
|
|
24
|
+
* reports it distinctly rather than claiming no store exists.
|
|
25
|
+
*/
|
|
26
|
+
const DISABLE_ENV = "JAM_DISABLE_SECRET_STORE";
|
|
27
|
+
export function secretStoreDisabled(env = process.env) {
|
|
28
|
+
return Boolean(env[DISABLE_ENV]);
|
|
29
|
+
}
|
|
30
|
+
function account() {
|
|
31
|
+
return userInfo().username;
|
|
32
|
+
}
|
|
33
|
+
function defaultRun(command, args, input) {
|
|
34
|
+
const result = spawnSync(command, args, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
...(input === undefined ? {} : { input }),
|
|
37
|
+
// No shell: arguments are passed as an array, so nothing is re-parsed.
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
status: result.status,
|
|
42
|
+
stdout: result.stdout ?? "",
|
|
43
|
+
stderr: result.stderr ?? "",
|
|
44
|
+
...(result.error ? { error: result.error } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Whether a command exists and runs at all. ENOENT is the signal we want. */
|
|
48
|
+
function canRun(run, command, args) {
|
|
49
|
+
return run(command, args).error?.code !== "ENOENT";
|
|
50
|
+
}
|
|
51
|
+
function parse(raw) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
if (!parsed?.baseUrl || !parsed.email || !parsed.apiToken)
|
|
55
|
+
return undefined;
|
|
56
|
+
return { baseUrl: parsed.baseUrl, email: parsed.email, apiToken: parsed.apiToken };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A corrupt entry is treated as absent. Reading is one step of credential
|
|
60
|
+
// resolution and must not turn into a crash on an unrelated command.
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* macOS login Keychain.
|
|
66
|
+
*
|
|
67
|
+
* The item is created and read by the same binary (`/usr/bin/security`), which
|
|
68
|
+
* is what keeps the OS from prompting for approval on every read.
|
|
69
|
+
*/
|
|
70
|
+
function macosStore(run) {
|
|
71
|
+
const base = ["-s", SERVICE, "-a", account()];
|
|
72
|
+
return {
|
|
73
|
+
label: "macOS Keychain",
|
|
74
|
+
read() {
|
|
75
|
+
const res = run("security", ["find-generic-password", ...base, "-w"]);
|
|
76
|
+
if (res.error || res.status !== 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
return parse(res.stdout.trim());
|
|
79
|
+
},
|
|
80
|
+
write(values) {
|
|
81
|
+
// `-U` updates in place rather than failing on an existing item.
|
|
82
|
+
// The secret rides in argv here because `security` reads its -w prompt
|
|
83
|
+
// from the controlling terminal, not stdin, and we already collected the
|
|
84
|
+
// token through our own masked prompt - prompting again would be worse.
|
|
85
|
+
// Visible only to this user, for the lifetime of one short-lived child.
|
|
86
|
+
const res = run("security", [
|
|
87
|
+
"add-generic-password",
|
|
88
|
+
...base,
|
|
89
|
+
"-U",
|
|
90
|
+
"-w",
|
|
91
|
+
JSON.stringify(values),
|
|
92
|
+
]);
|
|
93
|
+
if (res.error?.code === "ENOENT")
|
|
94
|
+
throw unavailable("security");
|
|
95
|
+
if (res.status !== 0)
|
|
96
|
+
throw new Error(`Keychain write failed: ${res.stderr.trim()}`);
|
|
97
|
+
},
|
|
98
|
+
clear() {
|
|
99
|
+
run("security", ["delete-generic-password", ...base]);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Linux libsecret, via the `secret-tool` CLI. */
|
|
104
|
+
function linuxStore(run) {
|
|
105
|
+
const attrs = ["service", SERVICE, "account", account()];
|
|
106
|
+
return {
|
|
107
|
+
label: "libsecret (secret-tool)",
|
|
108
|
+
read() {
|
|
109
|
+
const res = run("secret-tool", ["lookup", ...attrs]);
|
|
110
|
+
if (res.error || res.status !== 0)
|
|
111
|
+
return undefined;
|
|
112
|
+
return parse(res.stdout.trim());
|
|
113
|
+
},
|
|
114
|
+
write(values) {
|
|
115
|
+
// secret-tool reads the secret from stdin, so it never reaches argv.
|
|
116
|
+
const res = run("secret-tool", ["store", "--label", "JAM (Jira Agent MCP)", ...attrs], JSON.stringify(values));
|
|
117
|
+
if (res.error?.code === "ENOENT")
|
|
118
|
+
throw unavailable("secret-tool");
|
|
119
|
+
if (res.status !== 0)
|
|
120
|
+
throw new Error(`secret-tool store failed: ${res.stderr.trim()}`);
|
|
121
|
+
},
|
|
122
|
+
clear() {
|
|
123
|
+
run("secret-tool", ["clear", ...attrs]);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Windows: a file encrypted to the current user account with DPAPI.
|
|
129
|
+
*
|
|
130
|
+
* Credential Manager is not usable here - `cmdkey` can store a credential but
|
|
131
|
+
* cannot read one back, and reading it needs either a P/Invoke or a PowerShell
|
|
132
|
+
* module that is not installed by default.
|
|
133
|
+
*
|
|
134
|
+
* The confidentiality boundary is DPAPI's current-user binding. The 0o600 mode
|
|
135
|
+
* on the file is best-effort hardening on top, not the thing protecting it -
|
|
136
|
+
* Node's mode argument does not carry POSIX semantics on Windows.
|
|
137
|
+
*
|
|
138
|
+
* Kept separate from ~/.jam/config.yaml, which declares itself hand-editable
|
|
139
|
+
* and free of credentials.
|
|
140
|
+
*/
|
|
141
|
+
function windowsStore(run) {
|
|
142
|
+
const dir = join(homedir(), ".jam");
|
|
143
|
+
const path = join(dir, "credentials.dpapi");
|
|
144
|
+
// The path reaches PowerShell as an argument, never interpolated into the
|
|
145
|
+
// script text; the secret reaches it on stdin.
|
|
146
|
+
const decrypt = [
|
|
147
|
+
"-NoProfile",
|
|
148
|
+
"-NonInteractive",
|
|
149
|
+
"-Command",
|
|
150
|
+
"$p=$args[0]; if(!(Test-Path $p)){exit 1};" +
|
|
151
|
+
"$s=Get-Content $p -Raw | ConvertTo-SecureString;" +
|
|
152
|
+
"[Runtime.InteropServices.Marshal]::PtrToStringAuto(" +
|
|
153
|
+
"[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))",
|
|
154
|
+
"-args",
|
|
155
|
+
];
|
|
156
|
+
const encrypt = [
|
|
157
|
+
"-NoProfile",
|
|
158
|
+
"-NonInteractive",
|
|
159
|
+
"-Command",
|
|
160
|
+
"$in=[Console]::In.ReadToEnd();" +
|
|
161
|
+
"$in | ConvertTo-SecureString -AsPlainText -Force |" +
|
|
162
|
+
" ConvertFrom-SecureString | Set-Content $args[0] -NoNewline",
|
|
163
|
+
"-args",
|
|
164
|
+
];
|
|
165
|
+
return {
|
|
166
|
+
label: "Windows DPAPI (user-encrypted file)",
|
|
167
|
+
read() {
|
|
168
|
+
if (!existsSync(path))
|
|
169
|
+
return undefined;
|
|
170
|
+
const res = run("powershell", [...decrypt, path]);
|
|
171
|
+
if (res.error || res.status !== 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
return parse(res.stdout.trim());
|
|
174
|
+
},
|
|
175
|
+
write(values) {
|
|
176
|
+
mkdirSync(dir, { recursive: true });
|
|
177
|
+
const res = run("powershell", [...encrypt, path], JSON.stringify(values));
|
|
178
|
+
if (res.error?.code === "ENOENT")
|
|
179
|
+
throw unavailable("powershell");
|
|
180
|
+
if (res.status !== 0)
|
|
181
|
+
throw new Error(`DPAPI write failed: ${res.stderr.trim()}`);
|
|
182
|
+
try {
|
|
183
|
+
// Best-effort only; DPAPI is what actually protects the contents.
|
|
184
|
+
writeFileSync(path, "", { flag: "r+", mode: 0o600 });
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* ignore - the encryption, not the mode, is the boundary */
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
clear() {
|
|
191
|
+
rmSync(path, { force: true });
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function unavailable(command) {
|
|
196
|
+
return new SecretStoreUnavailableError(`No usable secret store was found on this system (${command} is not available).`, command === "secret-tool"
|
|
197
|
+
? "Install secret-tool (libsecret), or set JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN instead."
|
|
198
|
+
: "Set JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN instead.");
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The store for this system, or undefined when there is none that works.
|
|
202
|
+
*
|
|
203
|
+
* Being on Linux is not the same as having a secret store: a headless server or
|
|
204
|
+
* a container routinely has no libsecret and no session keyring. So the backend
|
|
205
|
+
* is probed, not assumed from `process.platform`.
|
|
206
|
+
*/
|
|
207
|
+
export function resolveSecretStore(run = defaultRun, env = process.env) {
|
|
208
|
+
if (secretStoreDisabled(env))
|
|
209
|
+
return undefined;
|
|
210
|
+
if (process.platform === "darwin") {
|
|
211
|
+
return canRun(run, "security", ["help"]) ? macosStore(run) : undefined;
|
|
212
|
+
}
|
|
213
|
+
if (process.platform === "win32") {
|
|
214
|
+
return canRun(run, "powershell", ["-NoProfile", "-Command", "$null"])
|
|
215
|
+
? windowsStore(run)
|
|
216
|
+
: undefined;
|
|
217
|
+
}
|
|
218
|
+
if (process.platform === "linux") {
|
|
219
|
+
return canRun(run, "secret-tool", ["--version"]) ? linuxStore(run) : undefined;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The credential chain's view of the store.
|
|
225
|
+
*
|
|
226
|
+
* Fail-soft by design, like the Windows registry source: a machine with no
|
|
227
|
+
* store, or an empty one, is a normal state that must not turn every command
|
|
228
|
+
* into an error. `jam auth login` is where a failure gets reported, because
|
|
229
|
+
* there the user asked for something specific.
|
|
230
|
+
*/
|
|
231
|
+
export class SecretStoreCredentialSource {
|
|
232
|
+
store;
|
|
233
|
+
/**
|
|
234
|
+
* The store is required, with no default that would resolve a real one.
|
|
235
|
+
* A parameter default cannot tell `undefined` meaning "this system has no
|
|
236
|
+
* store" from `undefined` meaning "not supplied" - and getting that wrong
|
|
237
|
+
* silently reaches the user's real keychain, including from a test.
|
|
238
|
+
*/
|
|
239
|
+
constructor(store) {
|
|
240
|
+
this.store = store;
|
|
241
|
+
}
|
|
242
|
+
read() {
|
|
243
|
+
const stored = this.store?.read();
|
|
244
|
+
if (!stored)
|
|
245
|
+
return {};
|
|
246
|
+
const values = {};
|
|
247
|
+
const byKey = {
|
|
248
|
+
JIRA_BASE_URL: stored.baseUrl,
|
|
249
|
+
JIRA_EMAIL: stored.email,
|
|
250
|
+
JIRA_API_TOKEN: stored.apiToken,
|
|
251
|
+
};
|
|
252
|
+
for (const key of CREDENTIAL_ENV_KEYS) {
|
|
253
|
+
const value = byKey[key]?.trim();
|
|
254
|
+
if (value)
|
|
255
|
+
values[key] = value;
|
|
256
|
+
}
|
|
257
|
+
return values;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CredentialValueSource, type RawCredentialValues } from "./process-env.js";
|
|
2
|
+
export type RegQueryFn = (name: string) => string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Reads Jira credentials from the Windows *User* environment (HKCU\Environment)
|
|
5
|
+
* rather than the current process's environment.
|
|
6
|
+
*
|
|
7
|
+
* This exists because a shell that had `setx JIRA_API_TOKEN ...` run in it does
|
|
8
|
+
* NOT see the new value until a new process is spawned - but every terminal
|
|
9
|
+
* spawned afterwards, including the one Claude Code's MCP child inherits from,
|
|
10
|
+
* does. Falling back to the registry closes that gap without asking the user
|
|
11
|
+
* to open a fresh terminal.
|
|
12
|
+
*
|
|
13
|
+
* A value read here is never logged, thrown in an error, or written back
|
|
14
|
+
* anywhere - `read()` only returns it to the caller for in-memory use.
|
|
15
|
+
*/
|
|
16
|
+
export declare class WindowsUserEnvCredentialSource implements CredentialValueSource {
|
|
17
|
+
private readonly queryFn;
|
|
18
|
+
constructor(queryFn?: RegQueryFn);
|
|
19
|
+
read(): RawCredentialValues;
|
|
20
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { CREDENTIAL_ENV_KEYS } from "./process-env.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads Jira credentials from the Windows *User* environment (HKCU\Environment)
|
|
5
|
+
* rather than the current process's environment.
|
|
6
|
+
*
|
|
7
|
+
* This exists because a shell that had `setx JIRA_API_TOKEN ...` run in it does
|
|
8
|
+
* NOT see the new value until a new process is spawned - but every terminal
|
|
9
|
+
* spawned afterwards, including the one Claude Code's MCP child inherits from,
|
|
10
|
+
* does. Falling back to the registry closes that gap without asking the user
|
|
11
|
+
* to open a fresh terminal.
|
|
12
|
+
*
|
|
13
|
+
* A value read here is never logged, thrown in an error, or written back
|
|
14
|
+
* anywhere - `read()` only returns it to the caller for in-memory use.
|
|
15
|
+
*/
|
|
16
|
+
export class WindowsUserEnvCredentialSource {
|
|
17
|
+
queryFn;
|
|
18
|
+
constructor(queryFn = defaultRegQuery) {
|
|
19
|
+
this.queryFn = queryFn;
|
|
20
|
+
}
|
|
21
|
+
read() {
|
|
22
|
+
if (process.platform !== "win32")
|
|
23
|
+
return {};
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const key of CREDENTIAL_ENV_KEYS) {
|
|
26
|
+
const value = this.queryFn(key)?.trim();
|
|
27
|
+
if (value)
|
|
28
|
+
out[key] = value;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const VALUE_LINE = /^\s*\S+\s+REG_(?:SZ|EXPAND_SZ)\s+(.*)$/;
|
|
34
|
+
// Built from a char code rather than a literal backslash escape - a lone
|
|
35
|
+
// backslash before a letter isn't a recognized JS escape and silently
|
|
36
|
+
// disappears, which would quietly turn this into an invalid registry path.
|
|
37
|
+
const HKCU_ENVIRONMENT_KEY = ["HKCU", "Environment"].join(String.fromCharCode(92));
|
|
38
|
+
function defaultRegQuery(name) {
|
|
39
|
+
const res = spawnSync("reg.exe", ["query", HKCU_ENVIRONMENT_KEY, "/v", name], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
windowsHide: true,
|
|
42
|
+
});
|
|
43
|
+
if (res.status !== 0 || !res.stdout)
|
|
44
|
+
return undefined;
|
|
45
|
+
for (const line of res.stdout.split(/\r?\n/)) {
|
|
46
|
+
const match = VALUE_LINE.exec(line);
|
|
47
|
+
if (match)
|
|
48
|
+
return match[1];
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
export declare function adfToText(doc: unknown): string;
|