kritya 0.8.2-beta
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 +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { openBrowser, startCallbackServer } from "./callback.js";
|
|
2
|
+
import { buildAuthorizeUrl, createPkce, discoverAuthServer, discoverProtectedResource, exchangeCode, randomState, registerClient, revokeToken, } from "./oauth.js";
|
|
3
|
+
import { deleteAuth, loadAuth, saveAuth } from "./tokens.js";
|
|
4
|
+
const pending = new Map();
|
|
5
|
+
export function pendingLogin(serverName) {
|
|
6
|
+
return pending.get(serverName);
|
|
7
|
+
}
|
|
8
|
+
/** Pull the `code` out of a pasted redirect URL, or accept a bare code. */
|
|
9
|
+
export function extractCode(input) {
|
|
10
|
+
const trimmed = input.trim();
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(trimmed);
|
|
13
|
+
const code = url.searchParams.get("code");
|
|
14
|
+
if (code)
|
|
15
|
+
return code;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Not a URL — treat the whole thing as the code.
|
|
19
|
+
}
|
|
20
|
+
return trimmed;
|
|
21
|
+
}
|
|
22
|
+
export async function beginLogin(opts) {
|
|
23
|
+
const existing = pending.get(opts.serverName);
|
|
24
|
+
if (existing)
|
|
25
|
+
existing.cancel();
|
|
26
|
+
const resource = await discoverProtectedResource(opts.serverUrl, opts.resourceMetadataUrl);
|
|
27
|
+
const issuer = resource.authorizationServers[0];
|
|
28
|
+
const meta = await discoverAuthServer(issuer);
|
|
29
|
+
const state = randomState();
|
|
30
|
+
const pkce = createPkce();
|
|
31
|
+
// The listener has to exist before registration: DCR pins the exact
|
|
32
|
+
// redirect_uri, so the port can't be chosen afterwards.
|
|
33
|
+
const callback = await startCallbackServer(state);
|
|
34
|
+
const scope = resource.scopesSupported?.join(" ") ?? meta.scopesSupported?.join(" ");
|
|
35
|
+
const registration = await reuseOrRegister(opts.serverUrl, meta, callback.redirectUri, scope);
|
|
36
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
37
|
+
meta,
|
|
38
|
+
clientId: registration.clientId,
|
|
39
|
+
redirectUri: callback.redirectUri,
|
|
40
|
+
challenge: pkce.challenge,
|
|
41
|
+
state,
|
|
42
|
+
resource: resource.resource,
|
|
43
|
+
scope,
|
|
44
|
+
});
|
|
45
|
+
let submitManual = () => { };
|
|
46
|
+
let cancelManual = () => { };
|
|
47
|
+
const manual = new Promise((resolve, reject) => {
|
|
48
|
+
submitManual = resolve;
|
|
49
|
+
cancelManual = reject;
|
|
50
|
+
});
|
|
51
|
+
// Nothing may ever consume this branch (the loopback usually wins); without
|
|
52
|
+
// this an unhandled rejection would surface when we cancel it.
|
|
53
|
+
manual.catch(() => { });
|
|
54
|
+
const browserOpened = openBrowser(authorizeUrl);
|
|
55
|
+
const completed = Promise.race([callback.waitForCode(), manual]).then(async (code) => {
|
|
56
|
+
callback.close();
|
|
57
|
+
const auth = await exchangeCode({
|
|
58
|
+
meta,
|
|
59
|
+
clientId: registration.clientId,
|
|
60
|
+
clientSecret: registration.clientSecret,
|
|
61
|
+
code,
|
|
62
|
+
verifier: pkce.verifier,
|
|
63
|
+
redirectUri: callback.redirectUri,
|
|
64
|
+
resource: resource.resource,
|
|
65
|
+
});
|
|
66
|
+
auth.serverName = opts.serverName;
|
|
67
|
+
saveAuth(opts.serverUrl, auth);
|
|
68
|
+
pending.delete(opts.serverName);
|
|
69
|
+
return auth;
|
|
70
|
+
});
|
|
71
|
+
completed.catch(() => pending.delete(opts.serverName));
|
|
72
|
+
const entry = {
|
|
73
|
+
serverName: opts.serverName,
|
|
74
|
+
serverUrl: opts.serverUrl,
|
|
75
|
+
authorizeUrl,
|
|
76
|
+
redirectUri: callback.redirectUri,
|
|
77
|
+
browserOpened,
|
|
78
|
+
completed,
|
|
79
|
+
submitCode: (codeOrUrl) => submitManual(extractCode(codeOrUrl)),
|
|
80
|
+
cancel: () => {
|
|
81
|
+
callback.close();
|
|
82
|
+
cancelManual(new Error("login cancelled"));
|
|
83
|
+
pending.delete(opts.serverName);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
pending.set(opts.serverName, entry);
|
|
87
|
+
return entry;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Reuse a client_id already registered with this authorization server when the
|
|
91
|
+
* redirect URI still matches. Re-registering on every login would leave a trail
|
|
92
|
+
* of dead client records on the provider's side, and some rate-limit it.
|
|
93
|
+
*/
|
|
94
|
+
async function reuseOrRegister(serverUrl, meta, redirectUri, scope) {
|
|
95
|
+
const prior = loadAuth(serverUrl);
|
|
96
|
+
// The loopback port changes every run, so a stored client_id is only reusable
|
|
97
|
+
// if the AS registered a wildcard-ish URI or the port happened to repeat.
|
|
98
|
+
// Registration is cheap and always correct, so only reuse on an exact match.
|
|
99
|
+
if (prior?.clientId && prior.issuer === meta.issuer && prior.redirectUri === redirectUri) {
|
|
100
|
+
return { clientId: prior.clientId, clientSecret: prior.clientSecret };
|
|
101
|
+
}
|
|
102
|
+
return registerClient(meta, redirectUri, scope);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Log out of a server: try to revoke server-side, then always delete locally.
|
|
106
|
+
* The two are reported separately on purpose — telling someone they are "logged
|
|
107
|
+
* out" when the token is merely forgotten, and still live on the provider's
|
|
108
|
+
* side, would be a lie they can't act on.
|
|
109
|
+
*/
|
|
110
|
+
export async function logout(serverUrl) {
|
|
111
|
+
const auth = loadAuth(serverUrl);
|
|
112
|
+
if (!auth)
|
|
113
|
+
return { revoked: false, hadToken: false };
|
|
114
|
+
const revoked = await revokeToken(auth);
|
|
115
|
+
deleteAuth(serverUrl);
|
|
116
|
+
return { revoked, hadToken: true };
|
|
117
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
3
|
+
import { debugLog } from "../config/debug.js";
|
|
4
|
+
import { isExpired, loadAuth, saveAuth } from "./tokens.js";
|
|
5
|
+
/**
|
|
6
|
+
* OAuth 2.1 for remote (Streamable HTTP) MCP servers.
|
|
7
|
+
*
|
|
8
|
+
* Every major hosted MCP server — Linear, Notion, Sentry, GitHub, Atlassian —
|
|
9
|
+
* gates on OAuth rather than a static bearer token, so `headers` in config only
|
|
10
|
+
* ever reached unauthenticated servers and ones handing out long-lived PATs.
|
|
11
|
+
* The flow implemented here is the one the MCP spec pins down:
|
|
12
|
+
*
|
|
13
|
+
* 401 + WWW-Authenticate → protected-resource metadata (RFC 9728)
|
|
14
|
+
* → authorization-server metadata (RFC 8414)
|
|
15
|
+
* → dynamic client registration (RFC 7591)
|
|
16
|
+
* → authorization code + PKCE (RFC 7636) in a browser
|
|
17
|
+
* → loopback redirect (RFC 8252) captures the code
|
|
18
|
+
* → token exchange, scoped with `resource` (RFC 8707)
|
|
19
|
+
*
|
|
20
|
+
* Two deliberate choices:
|
|
21
|
+
*
|
|
22
|
+
* - No client secret is required or expected. kritya is a public client on the
|
|
23
|
+
* user's own machine; there is nowhere to keep a secret that the user
|
|
24
|
+
* couldn't already read. PKCE is what makes the code exchange safe. A secret
|
|
25
|
+
* is stored only if an AS insists on issuing one.
|
|
26
|
+
* - Discovery degrades to spec defaults rather than failing. Real deployments
|
|
27
|
+
* are inconsistent about which .well-known documents they publish, and a
|
|
28
|
+
* missing metadata file is not a good enough reason to refuse to log in.
|
|
29
|
+
*/
|
|
30
|
+
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
31
|
+
const TOKEN_TIMEOUT_MS = 15_000;
|
|
32
|
+
/** Thrown when a server needs a login kritya doesn't have yet. Carries enough to start one. */
|
|
33
|
+
export class McpAuthRequiredError extends Error {
|
|
34
|
+
serverUrl;
|
|
35
|
+
resourceMetadataUrl;
|
|
36
|
+
constructor(serverUrl, resourceMetadataUrl) {
|
|
37
|
+
super("authentication required (run /mcp login)");
|
|
38
|
+
this.serverUrl = serverUrl;
|
|
39
|
+
this.resourceMetadataUrl = resourceMetadataUrl;
|
|
40
|
+
this.name = "McpAuthRequiredError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function ua() {
|
|
44
|
+
return { "user-agent": `kritya/${VERSION}`, accept: "application/json" };
|
|
45
|
+
}
|
|
46
|
+
async function getJson(url, timeoutMs = DISCOVERY_TIMEOUT_MS) {
|
|
47
|
+
const res = await fetch(url, { headers: ua(), signal: AbortSignal.timeout(timeoutMs) });
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
throw new Error(`HTTP ${res.status} from ${url}`);
|
|
50
|
+
return res.json();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pull `resource_metadata="…"` out of a 401's WWW-Authenticate challenge.
|
|
54
|
+
* Returns undefined when the header is missing or doesn't name one — common
|
|
55
|
+
* enough that callers fall back to well-known probing rather than giving up.
|
|
56
|
+
*/
|
|
57
|
+
export function parseWwwAuthenticate(header) {
|
|
58
|
+
if (!header)
|
|
59
|
+
return undefined;
|
|
60
|
+
const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(header);
|
|
61
|
+
return match?.[1];
|
|
62
|
+
}
|
|
63
|
+
/** Candidate .well-known URLs for a resource, per RFC 9728's path-aware rules. */
|
|
64
|
+
export function resourceMetadataUrls(serverUrl) {
|
|
65
|
+
const u = new URL(serverUrl);
|
|
66
|
+
const origin = u.origin;
|
|
67
|
+
const p = u.pathname.replace(/\/$/, "");
|
|
68
|
+
const urls = [`${origin}/.well-known/oauth-protected-resource`];
|
|
69
|
+
// Path-scoped variant first when the endpoint isn't at the root: a host can
|
|
70
|
+
// serve several distinct MCP resources under one origin.
|
|
71
|
+
if (p && p !== "")
|
|
72
|
+
urls.unshift(`${origin}/.well-known/oauth-protected-resource${p}`);
|
|
73
|
+
return urls;
|
|
74
|
+
}
|
|
75
|
+
export async function discoverProtectedResource(serverUrl, metadataUrl) {
|
|
76
|
+
const candidates = metadataUrl
|
|
77
|
+
? [metadataUrl, ...resourceMetadataUrls(serverUrl)]
|
|
78
|
+
: resourceMetadataUrls(serverUrl);
|
|
79
|
+
for (const url of candidates) {
|
|
80
|
+
try {
|
|
81
|
+
const doc = (await getJson(url));
|
|
82
|
+
if (doc?.authorization_servers?.length) {
|
|
83
|
+
return {
|
|
84
|
+
resource: doc.resource ?? serverUrl,
|
|
85
|
+
authorizationServers: doc.authorization_servers,
|
|
86
|
+
scopesSupported: doc.scopes_supported,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
debugLog(`mcp oauth protected-resource ${url}`, err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// No document: assume the resource server is also its own authorization
|
|
95
|
+
// server, which is how most single-tenant MCP deployments are set up.
|
|
96
|
+
return { resource: serverUrl, authorizationServers: [new URL(serverUrl).origin] };
|
|
97
|
+
}
|
|
98
|
+
/** Candidate .well-known URLs for an authorization server, RFC 8414 + OIDC. */
|
|
99
|
+
export function authServerMetadataUrls(issuer) {
|
|
100
|
+
const u = new URL(issuer);
|
|
101
|
+
const origin = u.origin;
|
|
102
|
+
const p = u.pathname.replace(/\/$/, "");
|
|
103
|
+
const urls = [];
|
|
104
|
+
if (p) {
|
|
105
|
+
urls.push(`${origin}/.well-known/oauth-authorization-server${p}`);
|
|
106
|
+
urls.push(`${origin}/.well-known/openid-configuration${p}`);
|
|
107
|
+
urls.push(`${origin}${p}/.well-known/openid-configuration`);
|
|
108
|
+
}
|
|
109
|
+
urls.push(`${origin}/.well-known/oauth-authorization-server`);
|
|
110
|
+
urls.push(`${origin}/.well-known/openid-configuration`);
|
|
111
|
+
return urls;
|
|
112
|
+
}
|
|
113
|
+
export async function discoverAuthServer(issuer) {
|
|
114
|
+
for (const url of authServerMetadataUrls(issuer)) {
|
|
115
|
+
try {
|
|
116
|
+
const doc = (await getJson(url));
|
|
117
|
+
if (doc?.authorization_endpoint && doc?.token_endpoint) {
|
|
118
|
+
return {
|
|
119
|
+
issuer: doc.issuer ?? issuer,
|
|
120
|
+
authorizationEndpoint: doc.authorization_endpoint,
|
|
121
|
+
tokenEndpoint: doc.token_endpoint,
|
|
122
|
+
registrationEndpoint: doc.registration_endpoint,
|
|
123
|
+
revocationEndpoint: doc.revocation_endpoint,
|
|
124
|
+
scopesSupported: doc.scopes_supported,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
debugLog(`mcp oauth as-metadata ${url}`, err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// RFC 8414 default endpoint layout — enough for servers that publish nothing.
|
|
133
|
+
const base = issuer.replace(/\/$/, "");
|
|
134
|
+
return {
|
|
135
|
+
issuer,
|
|
136
|
+
authorizationEndpoint: `${base}/authorize`,
|
|
137
|
+
tokenEndpoint: `${base}/token`,
|
|
138
|
+
registrationEndpoint: `${base}/register`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export function createPkce() {
|
|
142
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
143
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
144
|
+
return { verifier, challenge };
|
|
145
|
+
}
|
|
146
|
+
export function randomState() {
|
|
147
|
+
return crypto.randomBytes(16).toString("base64url");
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Register kritya with the authorization server. Public client, no secret
|
|
151
|
+
* requested: `token_endpoint_auth_method: "none"` plus PKCE is the OAuth 2.1
|
|
152
|
+
* shape for a native app.
|
|
153
|
+
*/
|
|
154
|
+
export async function registerClient(meta, redirectUri, scope) {
|
|
155
|
+
if (!meta.registrationEndpoint) {
|
|
156
|
+
throw new Error(`authorization server ${meta.issuer} does not support dynamic client registration; ` +
|
|
157
|
+
`register kritya manually and put the client_id in mcp-auth.json`);
|
|
158
|
+
}
|
|
159
|
+
const res = await fetch(meta.registrationEndpoint, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { ...ua(), "content-type": "application/json" },
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
client_name: "kritya",
|
|
164
|
+
client_uri: "https://github.com/GenAICloudDevOps/kritya",
|
|
165
|
+
redirect_uris: [redirectUri],
|
|
166
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
167
|
+
response_types: ["code"],
|
|
168
|
+
token_endpoint_auth_method: "none",
|
|
169
|
+
...(scope ? { scope } : {}),
|
|
170
|
+
}),
|
|
171
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const body = await res.text().catch(() => "");
|
|
175
|
+
throw new Error(`client registration failed: HTTP ${res.status}${body ? ` — ${body.slice(0, 200)}` : ""}`);
|
|
176
|
+
}
|
|
177
|
+
const doc = (await res.json());
|
|
178
|
+
if (!doc.client_id)
|
|
179
|
+
throw new Error("client registration returned no client_id");
|
|
180
|
+
return { clientId: doc.client_id, clientSecret: doc.client_secret };
|
|
181
|
+
}
|
|
182
|
+
export function buildAuthorizeUrl(opts) {
|
|
183
|
+
const url = new URL(opts.meta.authorizationEndpoint);
|
|
184
|
+
url.searchParams.set("response_type", "code");
|
|
185
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
186
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
187
|
+
url.searchParams.set("code_challenge", opts.challenge);
|
|
188
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
189
|
+
url.searchParams.set("state", opts.state);
|
|
190
|
+
// RFC 8707: binds the issued token to this MCP server, so a token minted for
|
|
191
|
+
// one resource can't be replayed against another on the same AS.
|
|
192
|
+
url.searchParams.set("resource", opts.resource);
|
|
193
|
+
if (opts.scope)
|
|
194
|
+
url.searchParams.set("scope", opts.scope);
|
|
195
|
+
return url.toString();
|
|
196
|
+
}
|
|
197
|
+
async function postToken(tokenEndpoint, params, clientSecret) {
|
|
198
|
+
const headers = {
|
|
199
|
+
...ua(),
|
|
200
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
201
|
+
};
|
|
202
|
+
// Confidential-client fallback: only when the AS insisted on issuing a secret.
|
|
203
|
+
if (clientSecret) {
|
|
204
|
+
const basic = Buffer.from(`${params.client_id}:${clientSecret}`).toString("base64");
|
|
205
|
+
headers.authorization = `Basic ${basic}`;
|
|
206
|
+
}
|
|
207
|
+
const res = await fetch(tokenEndpoint, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers,
|
|
210
|
+
body: new URLSearchParams(params).toString(),
|
|
211
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
212
|
+
});
|
|
213
|
+
const doc = (await res.json().catch(() => ({})));
|
|
214
|
+
if (!res.ok || doc.error || !doc.access_token) {
|
|
215
|
+
const detail = doc.error_description ?? doc.error ?? `HTTP ${res.status}`;
|
|
216
|
+
throw new Error(`token request failed: ${detail}`);
|
|
217
|
+
}
|
|
218
|
+
return doc;
|
|
219
|
+
}
|
|
220
|
+
export async function exchangeCode(opts) {
|
|
221
|
+
const doc = await postToken(opts.meta.tokenEndpoint, {
|
|
222
|
+
grant_type: "authorization_code",
|
|
223
|
+
code: opts.code,
|
|
224
|
+
redirect_uri: opts.redirectUri,
|
|
225
|
+
client_id: opts.clientId,
|
|
226
|
+
code_verifier: opts.verifier,
|
|
227
|
+
resource: opts.resource,
|
|
228
|
+
}, opts.clientSecret);
|
|
229
|
+
return {
|
|
230
|
+
resource: opts.resource,
|
|
231
|
+
issuer: opts.meta.issuer,
|
|
232
|
+
tokenEndpoint: opts.meta.tokenEndpoint,
|
|
233
|
+
revocationEndpoint: opts.meta.revocationEndpoint,
|
|
234
|
+
clientId: opts.clientId,
|
|
235
|
+
clientSecret: opts.clientSecret,
|
|
236
|
+
redirectUri: opts.redirectUri,
|
|
237
|
+
accessToken: doc.access_token,
|
|
238
|
+
refreshToken: doc.refresh_token,
|
|
239
|
+
expiresAt: doc.expires_in ? Date.now() + doc.expires_in * 1000 : undefined,
|
|
240
|
+
scope: doc.scope,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
export async function refreshToken(auth) {
|
|
244
|
+
if (!auth.refreshToken)
|
|
245
|
+
throw new Error("no refresh token — a new login is required");
|
|
246
|
+
const doc = await postToken(auth.tokenEndpoint, {
|
|
247
|
+
grant_type: "refresh_token",
|
|
248
|
+
refresh_token: auth.refreshToken,
|
|
249
|
+
client_id: auth.clientId,
|
|
250
|
+
resource: auth.resource,
|
|
251
|
+
}, auth.clientSecret);
|
|
252
|
+
return {
|
|
253
|
+
...auth,
|
|
254
|
+
accessToken: doc.access_token,
|
|
255
|
+
// An AS may rotate the refresh token; keep the old one when it doesn't.
|
|
256
|
+
refreshToken: doc.refresh_token ?? auth.refreshToken,
|
|
257
|
+
expiresAt: doc.expires_in ? Date.now() + doc.expires_in * 1000 : undefined,
|
|
258
|
+
scope: doc.scope ?? auth.scope,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Ask the AS to invalidate a token (RFC 7009). Returns false when the server
|
|
263
|
+
* doesn't advertise a revocation endpoint or rejects the call — the caller
|
|
264
|
+
* still deletes locally, but must say which of the two happened rather than
|
|
265
|
+
* claiming the token is dead when it is merely forgotten.
|
|
266
|
+
*/
|
|
267
|
+
export async function revokeToken(auth) {
|
|
268
|
+
if (!auth.revocationEndpoint)
|
|
269
|
+
return false;
|
|
270
|
+
const token = auth.refreshToken ?? auth.accessToken;
|
|
271
|
+
const hint = auth.refreshToken ? "refresh_token" : "access_token";
|
|
272
|
+
try {
|
|
273
|
+
const res = await fetch(auth.revocationEndpoint, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { ...ua(), "content-type": "application/x-www-form-urlencoded" },
|
|
276
|
+
body: new URLSearchParams({
|
|
277
|
+
token,
|
|
278
|
+
token_type_hint: hint,
|
|
279
|
+
client_id: auth.clientId,
|
|
280
|
+
}).toString(),
|
|
281
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
282
|
+
});
|
|
283
|
+
return res.ok;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
debugLog(`mcp oauth revoke ${auth.revocationEndpoint}`, err);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The token side of one server's session, shared by every request on that
|
|
292
|
+
* transport. Refreshes are serialized through a single in-flight promise:
|
|
293
|
+
* kritya connects to all servers in parallel and a transport can have several
|
|
294
|
+
* requests outstanding, so without this a batch of simultaneous 401s would
|
|
295
|
+
* fire a burst of refreshes and — against an AS that rotates refresh tokens —
|
|
296
|
+
* invalidate each other's grant.
|
|
297
|
+
*/
|
|
298
|
+
export class OAuthSession {
|
|
299
|
+
serverUrl;
|
|
300
|
+
auth;
|
|
301
|
+
refreshing;
|
|
302
|
+
constructor(serverUrl, auth) {
|
|
303
|
+
this.serverUrl = serverUrl;
|
|
304
|
+
this.auth = auth ?? loadAuth(serverUrl);
|
|
305
|
+
}
|
|
306
|
+
get hasAuth() {
|
|
307
|
+
return this.auth !== undefined;
|
|
308
|
+
}
|
|
309
|
+
/** Current access token, refreshing first if it is known to be expiring. */
|
|
310
|
+
async accessToken() {
|
|
311
|
+
if (!this.auth)
|
|
312
|
+
return undefined;
|
|
313
|
+
if (isExpired(this.auth) && this.auth.refreshToken) {
|
|
314
|
+
const next = await this.doRefresh(this.auth);
|
|
315
|
+
return next?.accessToken;
|
|
316
|
+
}
|
|
317
|
+
return this.auth.accessToken;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Called after a 401. Returns true when a refreshed token is now in place and
|
|
321
|
+
* the request is worth retrying; false when the user must log in again.
|
|
322
|
+
*/
|
|
323
|
+
async handleUnauthorized() {
|
|
324
|
+
if (!this.auth?.refreshToken)
|
|
325
|
+
return false;
|
|
326
|
+
const next = await this.doRefresh(this.auth);
|
|
327
|
+
return next !== undefined;
|
|
328
|
+
}
|
|
329
|
+
doRefresh(current) {
|
|
330
|
+
const inFlight = this.refreshing ??
|
|
331
|
+
(this.refreshing = refreshToken(current)
|
|
332
|
+
.then((next) => {
|
|
333
|
+
this.auth = next;
|
|
334
|
+
saveAuth(this.serverUrl, next);
|
|
335
|
+
return next;
|
|
336
|
+
})
|
|
337
|
+
.finally(() => {
|
|
338
|
+
this.refreshing = undefined;
|
|
339
|
+
}));
|
|
340
|
+
return inFlight.catch((err) => {
|
|
341
|
+
debugLog(`mcp oauth refresh ${this.serverUrl}`, err);
|
|
342
|
+
return undefined;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Where MCP server definitions come from and how they combine:
|
|
5
|
+
*
|
|
6
|
+
* - Global: `mcpServers` in ~/.kritya/config.json — the user's own machines.
|
|
7
|
+
* - Project: `.mcp.json` at the workspace root (the cross-tool convention
|
|
8
|
+
* also read by Claude Code, Cursor, and VS Code), shape
|
|
9
|
+
* `{ "mcpServers": { name: { command|url, ... } } }`. A repo can ship this
|
|
10
|
+
* file, and it launches processes / sends requests with the user's
|
|
11
|
+
* credentials — so it is part of the workspace trust gate and must only be
|
|
12
|
+
* loaded for trusted workspaces (the caller enforces this).
|
|
13
|
+
*
|
|
14
|
+
* `${VAR}` in any string value is expanded from the environment at load time,
|
|
15
|
+
* so a checked-in .mcp.json never needs to contain a literal secret
|
|
16
|
+
* (e.g. "Authorization": "Bearer ${LINEAR_API_KEY}").
|
|
17
|
+
*/
|
|
18
|
+
const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
19
|
+
/** Expand ${VAR} from process.env; unknown variables are left as-is so typos stay visible. */
|
|
20
|
+
export function expandVars(value) {
|
|
21
|
+
return value.replace(VAR_RE, (whole, name) => {
|
|
22
|
+
const v = process.env[name];
|
|
23
|
+
return v !== undefined ? v : whole;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Names of ${VAR}s still unexpanded in an already-expanded config.
|
|
28
|
+
*
|
|
29
|
+
* Leaving them literal keeps typos visible, but only if something actually
|
|
30
|
+
* looks: otherwise a missing LINEAR_API_KEY means we POST the literal string
|
|
31
|
+
* `Bearer ${LINEAR_API_KEY}` to a third party, get an opaque 401 back, and —
|
|
32
|
+
* because 401 is also how OAuth asks for a login — report it as "needs login".
|
|
33
|
+
* Callers use this to fail the server with the real reason instead.
|
|
34
|
+
*/
|
|
35
|
+
export function missingVars(cfg) {
|
|
36
|
+
const found = new Set();
|
|
37
|
+
const scan = (s) => {
|
|
38
|
+
if (!s)
|
|
39
|
+
return;
|
|
40
|
+
for (const m of s.matchAll(VAR_RE))
|
|
41
|
+
found.add(m[1]);
|
|
42
|
+
};
|
|
43
|
+
scan(cfg.command);
|
|
44
|
+
cfg.args?.forEach(scan);
|
|
45
|
+
scan(cfg.cwd);
|
|
46
|
+
scan(cfg.url);
|
|
47
|
+
for (const v of Object.values(cfg.env ?? {}))
|
|
48
|
+
scan(v);
|
|
49
|
+
for (const v of Object.values(cfg.headers ?? {}))
|
|
50
|
+
scan(v);
|
|
51
|
+
return [...found].sort();
|
|
52
|
+
}
|
|
53
|
+
function expandRecord(rec) {
|
|
54
|
+
if (!rec)
|
|
55
|
+
return undefined;
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [k, v] of Object.entries(rec))
|
|
58
|
+
out[k] = expandVars(v);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
export function expandServerConfig(cfg) {
|
|
62
|
+
return {
|
|
63
|
+
command: cfg.command !== undefined ? expandVars(cfg.command) : undefined,
|
|
64
|
+
args: cfg.args?.map(expandVars),
|
|
65
|
+
env: expandRecord(cfg.env),
|
|
66
|
+
cwd: cfg.cwd !== undefined ? expandVars(cfg.cwd) : undefined,
|
|
67
|
+
url: cfg.url !== undefined ? expandVars(cfg.url) : undefined,
|
|
68
|
+
headers: expandRecord(cfg.headers),
|
|
69
|
+
// Carried through verbatim: these are tool-name patterns, not values, and
|
|
70
|
+
// dropping them here would silently expose a server's whole tool list.
|
|
71
|
+
tools: cfg.tools,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function isServerConfig(v) {
|
|
75
|
+
if (!v || typeof v !== "object")
|
|
76
|
+
return false;
|
|
77
|
+
const cfg = v;
|
|
78
|
+
return typeof cfg.command === "string" || typeof cfg.url === "string";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read the workspace's `.mcp.json`. Malformed files and entries with neither
|
|
82
|
+
* `command` nor `url` are skipped silently — a broken project file should
|
|
83
|
+
* degrade to "no project servers", not break startup.
|
|
84
|
+
*/
|
|
85
|
+
export function loadProjectMcpServers(workspace) {
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(fs.readFileSync(path.join(workspace, ".mcp.json"), "utf8"));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (!parsed?.mcpServers || typeof parsed.mcpServers !== "object")
|
|
94
|
+
return undefined;
|
|
95
|
+
const servers = {};
|
|
96
|
+
for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
|
|
97
|
+
if (isServerConfig(cfg))
|
|
98
|
+
servers[name] = cfg;
|
|
99
|
+
}
|
|
100
|
+
return Object.keys(servers).length ? servers : undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Combine plugin, project, and global server definitions, expanding ${VAR} in
|
|
104
|
+
* each. Precedence on a name clash: global wins over project, which wins over
|
|
105
|
+
* a plugin -- a plugin is third-party-contributed, so anything the user or
|
|
106
|
+
* the workspace declares explicitly takes priority.
|
|
107
|
+
*/
|
|
108
|
+
export function mergeMcpServers(global, project, plugin) {
|
|
109
|
+
const merged = { ...plugin, ...project, ...global };
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const [name, cfg] of Object.entries(merged))
|
|
112
|
+
out[name] = expandServerConfig(cfg);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function pathExtensions() {
|
|
5
|
+
return (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
6
|
+
.split(";")
|
|
7
|
+
.map((e) => e.trim())
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
function isFile(p) {
|
|
11
|
+
try {
|
|
12
|
+
return fs.statSync(p).isFile();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Find the concrete file a bare command name refers to, applying PATHEXT the
|
|
20
|
+
* way a shell would. Returns undefined when nothing matches, so the caller can
|
|
21
|
+
* fall through to a plain spawn and let the OS produce the usual ENOENT rather
|
|
22
|
+
* than a substitute error that points at the wrong thing.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveWindowsCommand(command) {
|
|
25
|
+
const exts = pathExtensions();
|
|
26
|
+
const hasExt = exts.some((e) => command.toLowerCase().endsWith(e.toLowerCase()));
|
|
27
|
+
const isPath = command.includes("/") || command.includes("\\") || path.isAbsolute(command);
|
|
28
|
+
// An explicit path with an explicit extension is already unambiguous.
|
|
29
|
+
if (hasExt && isPath)
|
|
30
|
+
return isFile(command) ? command : undefined;
|
|
31
|
+
const bases = isPath
|
|
32
|
+
? [command]
|
|
33
|
+
: (process.env.PATH ?? "")
|
|
34
|
+
.split(path.delimiter)
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((dir) => path.join(dir, command));
|
|
37
|
+
for (const base of bases) {
|
|
38
|
+
if (hasExt) {
|
|
39
|
+
if (isFile(base))
|
|
40
|
+
return base;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const ext of exts) {
|
|
44
|
+
if (isFile(base + ext))
|
|
45
|
+
return base + ext;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Quote one argument so cmd.exe passes it through unchanged.
|
|
52
|
+
*
|
|
53
|
+
* Two layers: the standard Windows double-quote rules (so the child's own
|
|
54
|
+
* argv parsing sees the original string), then a caret escape of every
|
|
55
|
+
* character cmd.exe would still act on before the child ever runs.
|
|
56
|
+
*/
|
|
57
|
+
function escapeForCmd(arg) {
|
|
58
|
+
// Escape backslashes that precede a quote, and any trailing run of them,
|
|
59
|
+
// then wrap in quotes — the CommandLineToArgvW rules.
|
|
60
|
+
const quoted = `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`;
|
|
61
|
+
return quoted.replace(/[()[\]{}^=;!'+,`~%!&|<>"]/g, "^$&");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Decide how to spawn `command` with `args` on this platform. On non-Windows,
|
|
65
|
+
* and for ordinary .exe files on Windows, this is a passthrough; only batch
|
|
66
|
+
* files need the cmd.exe detour.
|
|
67
|
+
*/
|
|
68
|
+
export function planSpawn(command, args) {
|
|
69
|
+
if (os.platform() !== "win32")
|
|
70
|
+
return { command, args };
|
|
71
|
+
const resolved = resolveWindowsCommand(command);
|
|
72
|
+
if (!resolved)
|
|
73
|
+
return { command, args };
|
|
74
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
75
|
+
if (ext !== ".cmd" && ext !== ".bat")
|
|
76
|
+
return { command: resolved, args };
|
|
77
|
+
const comSpec = process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe";
|
|
78
|
+
const line = [resolved, ...args].map(escapeForCmd).join(" ");
|
|
79
|
+
return {
|
|
80
|
+
// /d skips AutoRun commands from the registry, /s keeps our outer quotes
|
|
81
|
+
// intact, /c runs and exits.
|
|
82
|
+
command: comSpec,
|
|
83
|
+
args: ["/d", "/s", "/c", `"${line}"`],
|
|
84
|
+
windowsVerbatimArguments: true,
|
|
85
|
+
};
|
|
86
|
+
}
|