Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,7 @@ coverage/
.DS_Store

# SSH key for AWS
jenkins.pem
jenkins.pem

# Local change log (Chinese summary)
change_log_cn.md
88 changes: 84 additions & 4 deletions client/src/routes/Jenkins.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { BASE } from "../lib/api";

const SERVER_BASE = BASE.replace(/\/api$/, "");
const MCP_URL_FALLBACK = "http://192.168.1.35/mcp-server/mcp";
const MCP_URL_GENERAL_HINT = "Enter the MCP server URL (e.g. https://<host>/mcp-server/mcp)";

export default function Jenkins() {
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [mcpUrl, setMcpUrl] = useState(MCP_URL_FALLBACK);
const [jenkinsToken, setJenkinsToken] = useState("");
const [mcpUrlHint, setMcpUrlHint] = useState(MCP_URL_FALLBACK);
const [tokenHint, setTokenHint] = useState("");
const [configError, setConfigError] = useState<string | null>(null);

useEffect(() => {
let isMounted = true;
async function loadHints() {
try {
const res = await fetch(`${SERVER_BASE}/jenkins/config`, {
credentials: "include",
});
if (!res.ok) {
throw new Error(res.statusText);
}
const data = await res.json().catch(() => ({}));
if (!isMounted) return;
const hintUrl = data?.mcpUrlHint || MCP_URL_FALLBACK;
const hintToken = data?.tokenHint || "";
setMcpUrlHint(hintUrl);
setTokenHint(hintToken);
setMcpUrl(hintUrl);
setJenkinsToken(hintToken);
setConfigError(null);
} catch (err: any) {
if (!isMounted) return;
setConfigError(err?.message || "Unable to load Jenkins defaults");
}
}

loadHints();
return () => {
isMounted = false;
};
}, []);

async function handleAsk() {
if (!question.trim()) return;
if (!question.trim() || !mcpUrl.trim() || !jenkinsToken.trim()) return;
setLoading(true);
setError(null);
setAnswer("");
Expand All @@ -19,7 +57,11 @@ export default function Jenkins() {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ question: question.trim() }),
body: JSON.stringify({
question: question.trim(),
mcpUrl: mcpUrl.trim() || undefined,
token: jenkinsToken.trim() || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as any)?.error || res.statusText);
Expand All @@ -34,6 +76,32 @@ export default function Jenkins() {
return (
<section style={{ display: "grid", gap: 12 }}>
<h1>Jenkins</h1>
<label style={{ display: "grid", gap: 4 }}>
<span style={{ fontWeight: 500 }}>MCP URL</span>
<input
type="text"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder={MCP_URL_GENERAL_HINT}
style={{ width: "100%", padding: 8, fontSize: 14 }}
/>
<span style={{ fontSize: 12, color: "#555" }}>
Hint: {MCP_URL_GENERAL_HINT}
</span>
</label>
<label style={{ display: "grid", gap: 4 }}>
<span style={{ fontWeight: 500 }}>Jenkins Token</span>
<input
type="password"
value={jenkinsToken}
onChange={(e) => setJenkinsToken(e.target.value)}
placeholder={tokenHint || "Enter Jenkins token"}
style={{ width: "100%", padding: 8, fontSize: 14 }}
/>
<span style={{ fontSize: 12, color: "#555" }}>
Hint: {tokenHint ? tokenHint : "Set JENKINS_TOKEN to prefill"}
</span>
</label>
<textarea
rows={4}
value={question}
Expand All @@ -42,7 +110,12 @@ export default function Jenkins() {
style={{ width: "100%", padding: 8, fontSize: 14 }}
/>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={handleAsk} disabled={loading || !question.trim()}>
<button
onClick={handleAsk}
disabled={
loading || !question.trim() || !mcpUrl.trim() || !jenkinsToken.trim()
}
>
{loading ? "Sending..." : "Ask"}
</button>
<button
Expand All @@ -51,11 +124,18 @@ export default function Jenkins() {
setQuestion("");
setAnswer("");
setError(null);
setMcpUrl(mcpUrlHint);
setJenkinsToken(tokenHint);
}}
>
Clear
</button>
</div>
{configError && (
<div style={{ color: "#a67c00", fontSize: 13 }}>
Using fallback MCP defaults: {configError}
</div>
)}
{error && <div style={{ color: "red", fontSize: 13 }}>{error}</div>}
<textarea
readOnly
Expand Down
13 changes: 10 additions & 3 deletions server/routes/jenkins.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ import { askJenkins } from '../src/agents/jenkins-agent.js';

const router = express.Router();

router.post('/ask', async (req, res) => {
router.get("/config", (req, res) => {
res.json({
mcpUrlHint: process.env.JENKINS_MCP_URL || "http://192.168.1.35/mcp-server/mcp",
tokenHint: process.env.JENKINS_TOKEN || "",
});
});

router.post("/ask", async (req, res) => {
try {
const { question } = req.body;
const { question, mcpUrl, token } = req.body;
if (!question) {
return res
.status(400)
.json({ error: "Missing 'question' field in body" });
}

console.log(`[JENKINS ASK] ${question}`);
const answer = await askJenkins(question);
const answer = await askJenkins(question, { mcpUrl, token });

res.json({
question,
Expand Down
34 changes: 15 additions & 19 deletions server/src/agents/jenkins-agent.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,34 @@
import path from "node:path";
import { Agent, run, MCPServerStreamableHttp } from "@openai/agents";

const DEFAULT_JENKINS_MCP_URL = "http://192.168.1.35:8090/mcp-server/mcp";

export async function askJenkins(question) {
export async function askJenkins(question, options = {}) {
const user = process.env.JENKINS_USER;
const token = process.env.JENKINS_TOKEN;
// construct Basic Auth
const authToken = Buffer.from(
`${process.env.JENKINS_USER}:${process.env.JENKINS_TOKEN}`
).toString("base64");
const token = options.token || process.env.JENKINS_TOKEN;
const mcpUrl = options.mcpUrl || process.env.JENKINS_MCP_URL || DEFAULT_JENKINS_MCP_URL;

if (!user || !token) {
throw new Error("JENKINS_USER or JENKINS_TOKEN is not set in the environment");
if (!user) {
throw new Error("JENKINS_USER is not set in the environment");
}

// 1. URL change /mcp-server/mcp
// 2. use requestInit.headers pass Authorization
console.log(authToken);
if (!token) {
throw new Error("JENKINS_TOKEN is required. Provide it in the request body or environment");
}

const authToken = Buffer.from(`${user}:${token}`).toString("base64");

const jenkinsMcp = new MCPServerStreamableHttp({
name: "jenkins-mcp",
url: "https://jenkins.ilessai.com/mcp-server/mcp",
url: mcpUrl,
requestInit: {
headers: {
Authorization: `Basic ${authToken}`,
},
},
});


await jenkinsMcp.connect();


try {
console.log("[askJenkins] connecting to Jenkins MCP…");
console.log(`[askJenkins] connecting to Jenkins MCP at ${mcpUrl}…`);
await jenkinsMcp.connect();
console.log("[askJenkins] connected. Listing tools…");

Expand Down Expand Up @@ -68,4 +64,4 @@ export async function askJenkins(question) {
await jenkinsMcp.close();
console.log("[askJenkins] MCP connection closed.");
}
}
}