From 26c132ac24fb69fcd08135bff160a3e2d5b6bed9 Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Thu, 22 Oct 2026 15:05:51 -0500 Subject: [PATCH 1/3] MCP at /mcp and personal agent tokens The server answers MCP (JSON-RPC over HTTP POST, stateless) with 22 tools, each an in-process call to its own API with the caller's headers: same sign-in, origin check, roles, audit, write lock and production gate. People make tokens for their agents (/api/agent-tokens), which act as them, marked 'via' the token's label in the audit; only a hash is kept, roles follow the person, an agent cannot make tokens. agent.tokens / agent.tokens.maxDays in hosted.properties. Co-Authored-By: Claude Opus 5.5 --- bin/smoke-hosted.sh | 37 ++ .../com/pointblue/dirxml/web/AgentTokens.java | 229 ++++++++ .../java/com/pointblue/dirxml/web/Api.java | 73 ++- .../java/com/pointblue/dirxml/web/Audit.java | 3 + .../java/com/pointblue/dirxml/web/Hosted.java | 5 + .../java/com/pointblue/dirxml/web/Mcp.java | 531 ++++++++++++++++++ .../java/com/pointblue/dirxml/web/Serve.java | 8 +- .../com/pointblue/dirxml/web/Session.java | 2 + .../pointblue/dirxml/web/AgentTokensTest.java | 74 +++ 9 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/pointblue/dirxml/web/AgentTokens.java create mode 100644 src/main/java/com/pointblue/dirxml/web/Mcp.java create mode 100644 src/test/java/com/pointblue/dirxml/web/AgentTokensTest.java diff --git a/bin/smoke-hosted.sh b/bin/smoke-hosted.sh index d4680ba..bc0a195 100755 --- a/bin/smoke-hosted.sh +++ b/bin/smoke-hosted.sh @@ -256,6 +256,41 @@ check("a wrong token is refused", ci.call("GET", "/api/session", headers={"Autho s, _, _ = ci.call("POST", "/api/workspaces/open", {"branch": "feature/x"}, headers={"Authorization": "Bearer reader-agent-token-0123456789"}, csrf=False) check("an agent without a role reads only", s == 403) +# personal agent tokens and the MCP endpoint (docs/agents.md): alice's agent acts as alice +s, made, _ = alice.call("POST", "/api/agent-tokens", {"label": "claude", "days": 30}) +check("a person makes a token for her agent; it is shown once", s == 200 and str(made.get("token", "")).startswith("idmw_") and made["label"] == "claude") +mine = {"Authorization": "Bearer " + made["token"]} +s, sess, _ = ci.call("GET", "/api/session", headers=mine) +check("her agent signs in as her, with her roles", s == 200 and sess["user"]["subject"] == "alice" and sess["user"]["editor"] and not sess["user"]["deployer"]) +check("an agent cannot make tokens", ci.call("POST", "/api/agent-tokens", {"label": "x"}, headers=mine, csrf=False)[0] == 403) +s, lst, _ = alice.call("GET", "/api/agent-tokens") +check("her token list shows the label, never the token", s == 200 and [t["label"] for t in lst["tokens"]] == ["claude"] and made["token"] not in json.dumps(lst)) +def mcp(body, headers=None): + h = dict(mine) + h.update(headers or {}) + return ci.call("POST", "/mcp", body, headers=h, csrf=False) +s, init, _ = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}}) +check("MCP: initialize answers with the protocol and the rules", s == 200 and init["result"]["protocolVersion"] == "2025-06-18" and "plan_deploy" in init["result"]["instructions"]) +s, tl, _ = mcp({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) +names = [t["name"] for t in tl["result"]["tools"]] +check("MCP: the tools are listed, the vault writes marked destructive", s == 200 and "run_operations" in names and "deploy" in names + and [t for t in tl["result"]["tools"] if t["name"] == "deploy"][0]["annotations"]["destructiveHint"]) +s, r, _ = mcp({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "session", "arguments": {}}}) +text = r["result"]["content"][0]["text"] +check("MCP: a tool call acts as her, and the page's CSRF token stays out", s == 200 and not r["result"]["isError"] and '"subject":"alice"' in text and "csrf" not in text) +s, r, _ = mcp({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "outline", "arguments": {}}}) +check("MCP: her branch's tree is read through the API", s == 200 and not r["result"]["isError"] and "Loop" in r["result"]["content"][0]["text"]) +s, r, _ = mcp({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "deploy", "arguments": {"env": "lab", "confirm": "lab"}}}) +check("MCP: a vault write is the API's to refuse (an editor, not a deployer)", s == 200 and r["result"]["isError"] and "deployer" in r["result"]["content"][0]["text"]) +s, _, _ = mcp({"jsonrpc": "2.0", "id": 6, "method": "tools/list"}, headers={"Host": "evil.example"}) +check("MCP: another host is refused", s == 403) +s, _, _ = anon.call("POST", "/mcp", {"jsonrpc": "2.0", "id": 7, "method": "tools/list"}, csrf=False) +check("MCP: without a token it says to sign in", s == 401) +check("MCP: GET has no stream (405)", ci.call("GET", "/mcp", headers=mine)[0] == 405) +s, _, _ = alice.call("POST", "/api/agent-tokens/revoke", {"id": made["id"]}) +check("she revokes the token", s == 200) +check("a revoked token no longer signs in", ci.call("GET", "/api/session", headers=mine)[0] == 401) + s, _, h = alice.call("GET", "/auth/logout") check("sign-out clears the cookie", s == 303 and "Max-Age=0" in h.get("set-cookie", "")) check("and the session is gone", alice.call("GET", "/api/session")[0] == 401) @@ -365,6 +400,8 @@ events = [(l["user"], l["event"], l["outcome"]) for l in lines] check("the audit log has sign-ins, the workspace, the edit, the commit and the sign-out, per user", ("alice", "sign-in", "ok") in events and ("alice", "workspace.open", "ok") in events and ("alice", "op/gcv.set", "ok") in events and ("alice", "commit+push", "ok") in events and ("bob", "workspace.open", "ok") in events and ("alice", "sign-out", "ok") in events and ("agent:ci", "workspace.open", "ok") in events) check("and no line carries a file's contents", all("Hosted Corp" not in json.dumps(l["details"]) for l in lines)) +check("her agent's calls are hers, marked via its token", any(l["user"] == "alice" and l.get("via") == "claude" for l in lines) + and any(l["user"] == "alice" and l["event"] == "agent-token-create" for l in lines) and all(made["token"] not in json.dumps(l) for l in lines)) print("smoke-hosted: all passed") PY # the local install's `setup` (docs/simple-install.md), written into a temporary folder diff --git a/src/main/java/com/pointblue/dirxml/web/AgentTokens.java b/src/main/java/com/pointblue/dirxml/web/AgentTokens.java new file mode 100644 index 0000000..510d279 --- /dev/null +++ b/src/main/java/com/pointblue/dirxml/web/AgentTokens.java @@ -0,0 +1,229 @@ +package com.pointblue.dirxml.web; + +import com.pointblue.dirxml.dev.json.Json; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +/** + * Personal agent tokens (docs/agents.md): a signed-in person makes one for an agent (Claude Code, Cursor, a script), + * and the agent then acts as that person: their roles, their connections, their workspaces, the audit saying "via" + * the token's label. Only a SHA-256 of each token is kept, with a snapshot of its owner, in + * {@code users//agent-tokens.json} (owner-only); the snapshot follows the person each time they use the page, + * so a change of roles reaches their tokens. Tokens expire; an agent cannot make one. + */ +final class AgentTokens { + + static final String FILE = "agent-tokens.json"; + static final String PREFIX = "idmw_"; + static final int DEFAULT_DAYS = 90; + + record Entry(String id, String label, String hash, Instant created, Instant expires, Instant lastUsed, Session.Principal who) { + Entry used(Instant at) { + return new Entry(id, label, hash, created, expires, at, who); + } + + Entry as(Session.Principal p) { + return new Entry(id, label, hash, created, expires, lastUsed, p); + } + } + + private final UserData users; + private final Path usersDir; + private final int maxDays; + private final Map byHash = new ConcurrentHashMap<>(); + private final SecureRandom random = new SecureRandom(); + + AgentTokens(UserData users, Path dataDir, int maxDays) throws IOException { + this.users = users; + this.usersDir = dataDir.resolve("users"); + this.maxDays = Math.max(1, maxDays); + if (Files.isDirectory(usersDir)) { + try (Stream s = Files.list(usersDir)) { + for (Path dir : (Iterable) s::iterator) { + Path f = dir.resolve(FILE); + if (Files.isRegularFile(f)) { + for (Entry e : read(f)) { + byHash.put(e.hash, e); + } + } + } + } + } + } + + int maxDays() { + return maxDays; + } + + /** The owner of a presented token, when it is one of ours and not expired; null otherwise. */ + Entry find(String token) { + if (token == null || !token.startsWith(PREFIX)) { + return null; + } + Entry e = byHash.get(hash(token)); + if (e == null || Instant.now().isAfter(e.expires)) { + return null; + } + // last used, kept to the minute and written at most every ten minutes + Instant now = Instant.now().truncatedTo(ChronoUnit.MINUTES); + if (e.lastUsed == null || e.lastUsed.isBefore(now.minus(10, ChronoUnit.MINUTES))) { + Entry u = e.used(now); + byHash.put(u.hash, u); + save(u.who.subject); + } + return byHash.get(e.hash); + } + + /** A new token for {@code who}; the only time its value is seen. */ + synchronized Map create(Session.Principal who, String label, int days) { + String l = label == null || label.isBlank() ? "agent" : label.trim(); + if (l.length() > 60) { + throw new TreeService.BadRequest("a label is at most 60 characters"); + } + if (byHash.values().stream().anyMatch(e -> e.who.subject.equals(who.subject) && e.label.equals(l))) { + throw new TreeService.BadRequest("you already have a token called '" + l + "'; revoke it or pick another label"); + } + int d = days <= 0 ? Math.min(DEFAULT_DAYS, maxDays) : days; + if (d > maxDays) { + throw new TreeService.BadRequest("a token lasts at most " + maxDays + " days on this server"); + } + byte[] raw = new byte[32]; + random.nextBytes(raw); + String token = PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Entry e = new Entry(Long.toHexString(random.nextLong() & Long.MAX_VALUE), l, hash(token), now, now.plus(d, ChronoUnit.DAYS), null, who); + byHash.put(e.hash, e); + save(who.subject); + Map m = view(e); + m.put("token", token); + return m; + } + + synchronized boolean revoke(Session.Principal who, String id) { + boolean removed = byHash.values().removeIf(e -> e.who.subject.equals(who.subject) && e.id.equals(id)); + if (removed) { + save(who.subject); + } + return removed; + } + + List> list(Session.Principal who) { + List> out = new ArrayList<>(); + byHash.values().stream().filter(e -> e.who.subject.equals(who.subject)).sorted((a, b) -> a.created.compareTo(b.created)).forEach(e -> out.add(view(e))); + return out; + } + + /** The person used the page: their tokens act with who they are now (name, groups, roles). */ + synchronized void refresh(Session.Principal who) { + boolean changed = false; + for (Entry e : new ArrayList<>(byHash.values())) { + if (e.who.subject.equals(who.subject) && !same(e.who, who)) { + byHash.put(e.hash, e.as(who)); + changed = true; + } + } + if (changed) { + save(who.subject); + } + } + + private static boolean same(Session.Principal a, Session.Principal b) { + return java.util.Objects.equals(a.name, b.name) && java.util.Objects.equals(a.email, b.email) && a.groups.equals(b.groups) + && a.editor == b.editor && a.deployer == b.deployer; + } + + private static Map view(Entry e) { + Map m = new LinkedHashMap<>(); + m.put("id", e.id); + m.put("label", e.label); + m.put("created", e.created.toString()); + m.put("expires", e.expires.toString()); + m.put("lastUsed", e.lastUsed == null ? null : e.lastUsed.toString()); + m.put("expired", Instant.now().isAfter(e.expires)); + return m; + } + + // ---- the file ---- + + private synchronized void save(String subject) { + List list = new ArrayList<>(); + Session.Principal who = null; + for (Entry e : byHash.values()) { + if (!e.who.subject.equals(subject)) { + continue; + } + who = e.who; + Map m = new LinkedHashMap<>(); + m.put("id", e.id); + m.put("label", e.label); + m.put("sha256", e.hash); + m.put("created", e.created.toString()); + m.put("expires", e.expires.toString()); + m.put("lastUsed", e.lastUsed == null ? null : e.lastUsed.toString()); + m.put("subject", e.who.subject); + m.put("name", e.who.name); + m.put("email", e.who.email); + m.put("groups", e.who.groups); + m.put("editor", e.who.editor); + m.put("deployer", e.who.deployer); + list.add(m); + } + try { + Path dir = who != null ? users.dir(who) : usersDir.resolve(Workspaces.safe(subject)); + Path f = dir.resolve(FILE); + if (list.isEmpty()) { + Files.deleteIfExists(f); + return; + } + Files.writeString(f, Json.pretty(Map.of("tokens", list)) + "\n", StandardCharsets.UTF_8); + PropertiesFile.ownerOnly(f); + } catch (IOException e) { + System.err.println("agent tokens: cannot write for " + subject + ": " + e.getMessage()); + } + } + + private static List read(Path f) throws IOException { + List out = new ArrayList<>(); + for (Object o : Json.asList(Json.asMap(Json.parse(Files.readString(f, StandardCharsets.UTF_8))).get("tokens"))) { + Map m = Json.asMap(o); + List groups = new ArrayList<>(); + for (Object g : Json.asList(m.get("groups"))) { + groups.add(String.valueOf(g)); + } + Session.Principal who = new Session.Principal(Json.asString(m.get("subject")), Json.asString(m.get("name")), Json.asString(m.get("email")), + groups, Boolean.TRUE.equals(m.get("editor")), Boolean.TRUE.equals(m.get("deployer"))); + String used = Json.asString(m.get("lastUsed")); + out.add(new Entry(Json.asString(m.get("id")), Json.asString(m.get("label")), Json.asString(m.get("sha256")), + Instant.parse(Json.asString(m.get("created"))), Instant.parse(Json.asString(m.get("expires"))), used == null ? null : Instant.parse(used), who)); + } + return out; + } + + static String hash(String token) { + try { + byte[] h = MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : h) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/com/pointblue/dirxml/web/Api.java b/src/main/java/com/pointblue/dirxml/web/Api.java index 4db4a73..d4a3c67 100644 --- a/src/main/java/com/pointblue/dirxml/web/Api.java +++ b/src/main/java/com/pointblue/dirxml/web/Api.java @@ -38,6 +38,9 @@ public final class Api implements HttpHandler { private final WebReleaseCheck release; private final Map agents; private final Map agentSessions = new java.util.concurrent.ConcurrentHashMap<>(); + /** Personal agent tokens (hosted mode; null when off), and the session each one signs in to, by the token's hash. */ + private AgentTokens tokens; + private final Map tokenSessions = new java.util.concurrent.ConcurrentHashMap<>(); private SetupService setup; // hosted mode only private ArchiveService archive; // hosted mode only (#76) @@ -51,6 +54,12 @@ public Api withSetup(SetupService setup, ArchiveService archive) { return this; } + /** Hosted mode: people make tokens for their agents, which then act as them. */ + public Api withAgentTokens(AgentTokens tokens) { + this.tokens = tokens; + return this; + } + public Api withRepository(RepositoryService repository) { this.repository = repository; return this; @@ -88,6 +97,24 @@ private Session agentSession(String token) { return null; } + /** An agent with a person's own token: a session as that person, marked with the token's label. */ + private Session personalSession(String token) { + AgentTokens.Entry e = tokens == null ? null : tokens.find(token); + if (e == null) { + return null; + } + String id = tokenSessions.get(e.hash()); + Session s = id == null ? null : sessions.get(id); + if (s == null || s.principal != e.who()) { + s = sessions.create(e.who()); + s.bearer = true; + s.via = e.label(); + tokenSessions.put(e.hash(), s.id); + audit.log(s, null, "sign-in", Map.of("provider", "agent-token"), "ok"); + } + return s; + } + /** * A session with no workspace yet — or one whose workspace this server has not opened (a restart: * the session came from the file, the clone is still on disk) — gets the person's last project @@ -129,7 +156,9 @@ private Session session(HttpExchange ex, Map query) { if (security.hostedMode()) { String auth = ex.getRequestHeaders().getFirst("Authorization"); if (auth != null && auth.regionMatches(true, 0, "Bearer ", 0, 7)) { - return restored(agentSession(auth.substring(7).trim())); + String token = auth.substring(7).trim(); + Session s = agentSession(token); + return restored(s != null ? s : personalSession(token)); } return restored(sessions.get(cookie(ex, COOKIE))); } @@ -165,6 +194,9 @@ public void handle(HttpExchange ex) throws IOException { } String path = ex.getRequestURI().getPath(); String method = ex.getRequestMethod(); + if (tokens != null && !session.bearer) { + tokens.refresh(session.principal); // their agents act with who they are now + } if (security.hostedMode() && !method.equals("GET") && !session.bearer) { String csrf = ex.getRequestHeaders().getFirst(CSRF_HEADER); if (csrf == null || !java.security.MessageDigest.isEqual(csrf.getBytes(StandardCharsets.UTF_8), session.csrf.getBytes(StandardCharsets.UTF_8))) { @@ -172,6 +204,11 @@ public void handle(HttpExchange ex) throws IOException { return; } } + if (path.startsWith("/api/agent-tokens")) { + // anyone signed in may make tokens: an agent does no more than its person may + send(ex, 200, agentTokensRoute(session, method, path, ex)); + return; + } if (!method.equals("GET") && !session.principal.editor) { send(ex, 403, error("your account may read but not change anything")); return; @@ -891,6 +928,40 @@ private static Map strings(Object o) { } /** A deploy's choices beyond its scope (#34): secrets mode, first-deploy leniency, drivers to delete. */ + /** {@code /api/agent-tokens}: the person's own tokens for their agents (docs/agents.md). */ + private Object agentTokensRoute(Session session, String method, String path, HttpExchange ex) throws IOException { + if (tokens == null) { + throw new TreeService.NotFound(security.hostedMode() ? "personal agent tokens are off on this server (agent.tokens=false)" : "local mode has no agent tokens: the session token is the key"); + } + if (session.bearer) { + throw new Forbidden("an agent cannot manage agent tokens; sign in to the page"); + } + if (method.equals("GET") && path.equals("/api/agent-tokens")) { + Map m = new LinkedHashMap<>(); + m.put("maxDays", tokens.maxDays()); + m.put("defaultDays", Math.min(AgentTokens.DEFAULT_DAYS, tokens.maxDays())); + m.put("tokens", tokens.list(session.principal)); + m.put("mcp", "/mcp"); + return m; + } + Map req = Json.asMap(Json.parse(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8))); + if (method.equals("POST") && path.equals("/api/agent-tokens")) { + int days = req.get("days") == null ? 0 : (int) Double.parseDouble(String.valueOf(req.get("days"))); + Map made = tokens.create(session.principal, Json.asString(req.get("label")), days); + audit.log(session, null, "agent-token-create", Map.of("label", String.valueOf(made.get("label")), "expires", String.valueOf(made.get("expires"))), "ok"); + return made; + } + if (method.equals("POST") && path.equals("/api/agent-tokens/revoke")) { + String id = Json.asString(req.get("id")); + if (!tokens.revoke(session.principal, id)) { + throw new TreeService.NotFound("no token " + id + " of yours"); + } + audit.log(session, null, "agent-token-revoke", Map.of("id", String.valueOf(id)), "ok"); + return Map.of("revoked", true); + } + throw new TreeService.NotFound(method + " " + path); + } + private static VaultService.DeployChoices deployChoices(Map req, boolean restart) { VaultService.DeployChoices c = new VaultService.DeployChoices(); c.restart = restart; diff --git a/src/main/java/com/pointblue/dirxml/web/Audit.java b/src/main/java/com/pointblue/dirxml/web/Audit.java index 860a37f..88a3ba3 100644 --- a/src/main/java/com/pointblue/dirxml/web/Audit.java +++ b/src/main/java/com/pointblue/dirxml/web/Audit.java @@ -74,6 +74,9 @@ public synchronized void log(Session session, Workspace ws, String event, Map m = new LinkedHashMap<>(); m.put("time", Instant.now().toString()); m.put("user", session == null ? null : session.principal.subject); + if (session != null && session.via != null) { + m.put("via", session.via); // an agent acting for the person, by its token's label + } m.put("workspace", ws == null ? null : ws.id); m.put("project", ws == null || ws.id.indexOf('/') < 0 ? null : ws.id.substring(0, ws.id.indexOf('/'))); m.put("branch", ws == null ? null : ws.branch); diff --git a/src/main/java/com/pointblue/dirxml/web/Hosted.java b/src/main/java/com/pointblue/dirxml/web/Hosted.java index b5ffce6..ccba1e7 100644 --- a/src/main/java/com/pointblue/dirxml/web/Hosted.java +++ b/src/main/java/com/pointblue/dirxml/web/Hosted.java @@ -68,6 +68,9 @@ public final class Hosted { public final Path environmentsFile; /** Each person's own directory: credentials, profile. */ public final UserData users; + /** Personal agent tokens ({@code agent.tokens}, default on) and their longest life ({@code agent.tokens.maxDays}, default 365). */ + public final boolean agentTokens; + public final int agentTokensMaxDays; /** * Where a person's environments come from: the definitions merged with their own credentials @@ -96,6 +99,8 @@ private Hosted(Path file, Properties p) { this.bind = p.getProperty("bind", "127.0.0.1").trim(); this.port = Integer.parseInt(p.getProperty("port", "8080").trim()); this.dataDir = Path.of(p.getProperty("data.dir", "data").trim()).toAbsolutePath().normalize(); + this.agentTokens = !"false".equalsIgnoreCase(p.getProperty("agent.tokens", "true").trim()); + this.agentTokensMaxDays = Integer.parseInt(p.getProperty("agent.tokens.maxDays", "365").trim()); // projects=a,b with project..repo.*; the older single repo.* keys are the project named by repo.name for (String n : list(p.getProperty("projects", ""))) { Project pr = project(p, "project." + n + ".", n); diff --git a/src/main/java/com/pointblue/dirxml/web/Mcp.java b/src/main/java/com/pointblue/dirxml/web/Mcp.java new file mode 100644 index 0000000..9dd84f8 --- /dev/null +++ b/src/main/java/com/pointblue/dirxml/web/Mcp.java @@ -0,0 +1,531 @@ +package com.pointblue.dirxml.web; + +import com.pointblue.dirxml.dev.json.Json; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpPrincipal; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The server as an MCP server ({@code docs/agents.md}): JSON-RPC 2.0 over HTTP POST at {@code /mcp}, stateless, the + * Streamable HTTP transport without a server-sent stream ({@code GET} answers 405, as the specification allows). + * Every tool call is a call to this server's own API, made in-process with the caller's own headers: the same sign-in, + * origin check, roles, audit line, per-environment write lock and production gate as the page. Nothing here decides + * what an agent may do; the API does. + */ +final class Mcp implements HttpHandler { + + static final List PROTOCOLS = List.of("2025-06-18", "2025-03-26", "2024-11-05"); + private static final int MAX_TEXT = 200_000; + + private static final String INSTRUCTIONS = String.join("\n", + "DirXMLDevWeb: an IDM driver set as code (a git workspace of the branch the person has open) and its Identity Vaults.", + "Rules, the same as the page's:", + "1. Orient with session, outline, validate. Edit the tree with run_operations: preview (dryRun, the default) before applying; list_operations names every operation and its arguments.", + "2. Validate after edits; commit with a message. Never commit secrets.", + "3. Before any vault write, run plan_deploy and show the person the plan. deploy, rollback and driver_lifecycle need confirm = the environment's name, typed by the person in chat. Never fill it in yourself.", + "4. Production deploys come from main, pushed and current; the server refuses otherwise. Never work around a refusal: report it.", + "5. Never print a secret; tools never return one.", + "The agent kit (docs and skills for this version) is at /api/agent-kit."); + + private final HttpHandler api; + private final String version; + private final List tools = new ArrayList<>(); + + Mcp(HttpHandler api, String version) { + this.api = api; + this.version = version; + define(); + } + + // ---- the transport ---- + + @Override + public void handle(HttpExchange ex) throws IOException { + try { + if (!ex.getRequestMethod().equals("POST")) { + ex.getResponseHeaders().set("Allow", "POST"); + send(ex, 405, Map.of("error", "POST JSON-RPC to this endpoint; no server-sent stream is offered")); + return; + } + // signed in, from an allowed origin: exactly what the API says about these headers + Reply who = call(ex, "GET", "/api/session", null); + if (who.status != 200) { + send(ex, who.status == 401 || who.status == 403 ? who.status : 401, Json.parse(who.body)); + return; + } + Object parsed; + try { + parsed = Json.parse(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + } catch (RuntimeException e) { + send(ex, 400, error(null, -32700, "not JSON: " + e.getMessage())); + return; + } + if (parsed instanceof List batch) { + List out = new ArrayList<>(); + for (Object o : batch) { + Object r = message(ex, o); + if (r != null) { + out.add(r); + } + } + if (out.isEmpty()) { + accepted(ex); + } else { + send(ex, 200, out); + } + return; + } + Object r = message(ex, parsed); + if (r == null) { + accepted(ex); + } else { + send(ex, 200, r); + } + } finally { + ex.close(); + } + } + + /** One JSON-RPC message: a response, or null for a notification. */ + private Object message(HttpExchange ex, Object o) { + if (!(o instanceof Map)) { + return error(null, -32600, "a JSON-RPC message is an object"); + } + Map m = Json.asMap(o); + Object id = m.get("id"); + String method = Json.asString(m.get("method")); + if (method == null) { + return id == null ? null : error(id, -32600, "no method"); + } + if (id == null) { + return null; // notifications/initialized, notifications/cancelled: nothing to answer + } + Map params = m.get("params") instanceof Map p ? Json.asMap(p) : Map.of(); + try { + switch (method) { + case "initialize": { + String asked = Json.asString(params.get("protocolVersion")); + Map r = new LinkedHashMap<>(); + r.put("protocolVersion", asked != null && PROTOCOLS.contains(asked) ? asked : PROTOCOLS.get(0)); + r.put("capabilities", Map.of("tools", Map.of("listChanged", false))); + r.put("serverInfo", Map.of("name", "dirxmldevweb", "version", version)); + r.put("instructions", INSTRUCTIONS); + return result(id, r); + } + case "ping": + return result(id, Map.of()); + case "tools/list": { + List list = new ArrayList<>(); + for (Tool t : tools) { + Map d = new LinkedHashMap<>(); + d.put("name", t.name); + d.put("description", t.description); + d.put("inputSchema", t.schema); + if (t.writes) { + d.put("annotations", Map.of("destructiveHint", true, "readOnlyHint", false)); + } else { + d.put("annotations", Map.of("readOnlyHint", t.readOnly)); + } + list.add(d); + } + return result(id, Map.of("tools", list)); + } + case "tools/call": { + String name = Json.asString(params.get("name")); + Map args = params.get("arguments") instanceof Map a ? Json.asMap(a) : Map.of(); + for (Tool t : tools) { + if (t.name.equals(name)) { + return result(id, run(ex, t, args)); + } + } + return error(id, -32602, "no tool '" + name + "'"); + } + default: + return error(id, -32601, "method not found: " + method); + } + } catch (RuntimeException | IOException e) { + return error(id, -32603, String.valueOf(e.getMessage())); + } + } + + private Map run(HttpExchange ex, Tool t, Map args) throws IOException { + Map out = new LinkedHashMap<>(); + String missing = t.missing(args); + Reply r; + if (missing != null) { + r = new Reply(400, Json.compact(Map.of("error", missing))); + } else { + Request q = t.request.apply(new Args(ex, args)); + r = call(ex, q.method, q.path, q.body); + } + String text = r.body; + if (t.name.equals("session") && r.status == 200) { + // the page's CSRF token is the page's; an agent signs each call with its bearer token + Map s = new LinkedHashMap<>(Json.asMap(Json.parse(text))); + s.remove("csrf"); + text = Json.compact(s); + } + if (text.length() > MAX_TEXT) { + text = text.substring(0, MAX_TEXT) + "\n… cut at " + MAX_TEXT + " characters; narrow the request (a driver, a path)"; + } + out.put("content", List.of(Map.of("type", "text", "text", text))); + out.put("isError", r.status >= 400); + return out; + } + + // ---- the tools: each a call to the page's own API ---- + + private record Request(String method, String path, Object body) { + } + + /** A tool's arguments, and the exchange they came with (to read the tree's revision first). */ + final class Args { + final HttpExchange ex; + final Map m; + + Args(HttpExchange ex, Map m) { + this.ex = ex; + this.m = m; + } + + String s(String k) { + return Json.asString(m.get(k)); + } + + List list(String k) { + List out = new ArrayList<>(); + if (m.get(k) instanceof List l) { + for (Object o : l) { + out.add(String.valueOf(o)); + } + } else if (m.get(k) instanceof String s && !s.isBlank()) { + out.add(s); + } + return out; + } + + String q(String k) { + String v = s(k); + return v == null ? "" : "&" + k + "=" + URLEncoder.encode(v, StandardCharsets.UTF_8); + } + + /** The tree's current revision, read the way the page reads it. */ + String revision() throws IOException { + Reply o = call(ex, "GET", "/api/outline", null); + Object rev = o.status == 200 ? Json.asMap(Json.parse(o.body)).get("revision") : null; + return rev == null ? null : String.valueOf(rev); + } + } + + private static final class Tool { + final String name; + final String description; + final Map schema; + final boolean readOnly; + final boolean writes; + final List required; + final Fn request; + + Tool(String name, String description, Map props, List required, boolean readOnly, boolean writes, Fn request) { + this.name = name; + this.description = description; + Map s = new LinkedHashMap<>(); + s.put("type", "object"); + s.put("properties", props); + if (!required.isEmpty()) { + s.put("required", required); + } + this.schema = s; + this.required = required; + this.readOnly = readOnly; + this.writes = writes; + this.request = request; + } + + String missing(Map args) { + for (String k : required) { + Object v = args.get(k); + if (v == null || (v instanceof String str && str.isBlank())) { + return "'" + k + "' is required"; + } + } + return null; + } + } + + @FunctionalInterface + private interface Fn { + Request apply(Args a) throws IOException; + } + + private static Map str(String description) { + return Map.of("type", "string", "description", description); + } + + private static Map strs(String description) { + return Map.of("type", "array", "items", Map.of("type", "string"), "description", description); + } + + private static Map bool(String description) { + return Map.of("type", "boolean", "description", description); + } + + private static Map props(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put((String) kv[i], kv[i + 1]); + } + return m; + } + + private void tool(String name, String description, Map props, List required, boolean writes, Fn request) { + tools.add(new Tool(name, description, props, required, !writes, writes, request)); + } + + private void edit(String name, String description, Map props, List required, Fn request) { + tools.add(new Tool(name, description, props, required, false, false, request)); + } + + private void define() { + // orientation + tool("session", "Who you are acting as, your roles, the project and branch open, and the server's and core's versions.", + props(), List.of(), false, a -> new Request("GET", "/api/session", null)); + tool("outline", "The tree: the driver set, its drivers and every policy, resource and object, with the tree's revision.", + props(), List.of(), false, a -> new Request("GET", "/api/outline", null)); + tool("read_artifact", "One artifact's content and details, by its tree path (e.g. drivers/AD/subscriber/sub-ctp, library/NOVLLIBAJC-JS).", + props("path", str("the artifact's tree path, as outline lists it")), List.of("path"), false, + a -> new Request("GET", "/api/artifact?" + a.q("path").substring(1), null)); + tool("read_file", "A file of the tree, by its path relative to the tree (e.g. drivers/AD/driver.xml, overrides/QA.properties).", + props("file", str("the file's path in the tree")), List.of("file"), false, + a -> new Request("GET", "/api/file?" + a.q("file").substring(1), null)); + tool("validate", "The engine's own checks over the whole tree: compile, linkage, GCVs, mapping tables, filter and schema map. Errors block a deploy.", + props(), List.of(), false, a -> new Request("GET", "/api/validate", null)); + tool("fishbone", "A driver's policy flow: every policy set in order, with what each holds.", + props("driver", str("the driver's name")), List.of("driver"), false, a -> new Request("GET", "/api/fishbone?" + a.q("driver").substring(1), null)); + tool("git_status", "The workspace's branch, uncommitted files, commits not pushed, and where it stands against the base branch.", + props(), List.of(), false, a -> new Request("GET", "/api/git/status", null)); + tool("git_diff", "What differs from the last commit (against=head, the default) or from the base branch (against=base).", + props("against", str("head (default) or base")), List.of(), false, + a -> new Request("GET", "/api/git/diff?against=" + (a.s("against") == null ? "head" : URLEncoder.encode(a.s("against"), StandardCharsets.UTF_8)), null)); + + // editing the tree + tool("list_operations", "Every edit operation the tree takes (policy.add, gcv.set, driver.set, override.set, environments.set, …) with its arguments.", + props(), List.of(), false, a -> new Request("GET", "/api/catalog", null)); + edit("run_operations", + "Edit the tree with one or more operations, applied together or not at all. dryRun (the default) previews what would change and writes nothing; " + + "run again with dryRun false to apply. Validate afterwards. File arguments take the content inline.", + props("operations", Map.of("type", "array", "description", "[{name, args: {…}, files: {argName: content}}], names and args as list_operations gives them", + "items", Map.of("type", "object")), + "dryRun", bool("preview only (default true)")), + List.of("operations"), + a -> { + List ops = new ArrayList<>(); + if (a.m.get("operations") instanceof List l) { + for (Object o : l) { + Map op = Json.asMap(o); + Map one = new LinkedHashMap<>(); + one.put("name", op.get("name")); + one.put("args", op.get("args") == null ? Map.of() : op.get("args")); + one.put("files", op.get("files") == null ? Map.of() : op.get("files")); + ops.add(one); + } + } + Map body = new LinkedHashMap<>(); + body.put("ops", ops); + body.put("dryRun", !Boolean.FALSE.equals(a.m.get("dryRun"))); + body.put("revision", a.revision()); + return new Request("POST", "/api/batch", body); + }); + edit("commit", "Commit the workspace's changes with a message, and push them when push is true.", + props("message", str("the commit message"), "push", bool("push to the repository too")), List.of("message"), + a -> new Request("POST", "/api/git/commit", Map.of("message", a.s("message"), "push", Boolean.TRUE.equals(a.m.get("push"))))); + + // the vaults: reads + tool("environments", "The environments this project deploys to, with tier (dev, test, prd) and whether you are connected to each.", + props(), List.of(), false, a -> new Request("GET", "/api/vault/environments", null)); + tool("compare", "The tree against an environment's vault: every difference a deploy would write. Nothing is written.", + props("env", str("environment name"), "drivers", strs("only these drivers (the tree's names); all when omitted")), List.of("env"), false, + a -> new Request("GET", "/api/vault/diff?" + a.q("env").substring(1) + drivers(a), null)); + tool("compare_environments", "Two environments' vaults side by side: drivers and objects only one holds, values that differ, likely renames, and the rest with a likely cause. Nothing is written.", + props("env", str("one environment"), "with", str("the other")), List.of("env", "with"), false, + a -> new Request("GET", "/api/vault/compare-envs?" + a.q("env").substring(1) + a.q("with"), null)); + tool("plan_deploy", "What a deploy to an environment would do, step by step, and what stands in its way (validation, production's rules). Nothing is written. Show this to the person before deploy.", + props("env", str("environment name"), "drivers", strs("only these drivers; the whole driver set when omitted"), + "restart", bool("restart drivers a change needs restarted (default true)"), + "secrets", str("none (default), missing, or all")), + List.of("env"), false, a -> new Request("POST", "/api/vault/plan", vaultBody(a))); + tool("driver_status", "The drivers' state in an environment (running, stopped), or one driver's.", + props("env", str("environment name"), "driver", str("one driver (optional)")), List.of("env"), false, + a -> new Request("GET", "/api/vault/driver-status?" + a.q("env").substring(1) + a.q("driver"), null)); + tool("trace", "A driver's recent trace in an environment.", + props("env", str("environment name"), "driver", str("the driver")), List.of("env", "driver"), false, + a -> new Request("GET", "/api/vault/trace-show?" + a.q("env").substring(1) + a.q("driver"), null)); + tool("snapshots", "The snapshots earlier deploys took in an environment (what a rollback puts back).", + props("env", str("environment name")), List.of("env"), false, a -> new Request("GET", "/api/vault/snapshots?" + a.q("env").substring(1), null)); + + // the simulator + tool("simulate", "Run the project's simulator cases (cases/) against the tree, optionally with an environment's values.", + props("env", str("apply this environment's overrides (optional)")), List.of(), false, + a -> new Request("POST", "/api/sim/run", a.s("env") == null ? Map.of() : Map.of("env", a.s("env")))); + + // the vaults: writes, gated by the server (role, confirm, production's rules, one write per environment at a time) + tool("deploy", "Deploy the tree to an environment. Only after plan_deploy has been shown to the person and they typed the environment's name in chat as confirm. The server refuses otherwise.", + props("env", str("environment name"), "drivers", strs("only these drivers; the whole driver set when omitted"), + "confirm", str("the environment's name, typed by the person"), "restart", bool("restart drivers a change needs restarted (default true)"), + "secrets", str("none (default), missing, or all")), + List.of("env", "confirm"), true, a -> { + Map b = vaultBody(a); + b.put("confirm", a.s("confirm")); + return new Request("POST", "/api/vault/deploy", b); + }); + tool("rollback", "Put back the snapshot a deploy took. Only when the person asks, with the environment's name typed by them as confirm.", + props("env", str("environment name"), "snapshot", str("the snapshot, as snapshots lists it"), "confirm", str("the environment's name, typed by the person")), + List.of("env", "snapshot", "confirm"), true, + a -> new Request("POST", "/api/vault/rollback", Map.of("env", a.s("env"), "snapshot", a.s("snapshot"), "confirm", a.s("confirm")))); + tool("driver_lifecycle", "Start, stop or restart a driver in an environment. Only when the person asks, with the environment's name typed by them as confirm.", + props("env", str("environment name"), "driver", str("the driver"), "action", str("start, stop or restart"), "confirm", str("the environment's name, typed by the person")), + List.of("env", "driver", "action", "confirm"), true, + a -> new Request("POST", "/api/vault/driver", Map.of("env", a.s("env"), "driver", a.s("driver"), "action", a.s("action"), "confirm", a.s("confirm")))); + } + + private static String drivers(Args a) { + StringBuilder sb = new StringBuilder(); + for (String d : a.list("drivers")) { + sb.append("&driver=").append(URLEncoder.encode(d, StandardCharsets.UTF_8)); + } + return sb.toString(); + } + + private static Map vaultBody(Args a) { + Map b = new LinkedHashMap<>(); + b.put("env", a.s("env")); + b.put("drivers", a.list("drivers")); + b.put("restart", !Boolean.FALSE.equals(a.m.get("restart"))); + b.put("secrets", a.s("secrets") == null ? "none" : a.s("secrets")); + return b; + } + + // ---- an in-process call to the API, with the caller's headers ---- + + private record Reply(int status, String body) { + } + + private Reply call(HttpExchange from, String method, String pathAndQuery, Object body) throws IOException { + String uri = pathAndQuery; + // local mode's session token may come as ?token= on the MCP URL + String raw = from.getRequestURI().getRawQuery(); + if (raw != null) { + for (String part : raw.split("&")) { + if (part.startsWith("token=")) { + uri += (uri.contains("?") ? "&" : "?") + part; + } + } + } + Local ex = new Local(from, method, URI.create(uri), body == null ? new byte[0] : Json.compact(body).getBytes(StandardCharsets.UTF_8)); + for (String h : List.of("Host", "Origin", "Authorization", Security.HEADER, "Cookie", "X-Forwarded-For", "X-Forwarded-Proto")) { + String v = from.getRequestHeaders().getFirst(h); + if (v != null) { + ex.requestHeaders.set(h, v); + } + } + api.handle(ex); + return new Reply(ex.status, ex.out.toString(StandardCharsets.UTF_8)); + } + + /** A request made inside the server: the caller's identity, a path of the API, the reply kept in memory. */ + private static final class Local extends HttpExchange { + final HttpExchange from; + final String method; + final URI uri; + final Headers requestHeaders = new Headers(); + final Headers responseHeaders = new Headers(); + final InputStream in; + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + int status = 500; + + Local(HttpExchange from, String method, URI uri, byte[] body) { + this.from = from; + this.method = method; + this.uri = uri; + this.in = new ByteArrayInputStream(body); + if (body.length > 0) { + requestHeaders.set("Content-Type", "application/json"); + } + } + + @Override public Headers getRequestHeaders() { return requestHeaders; } + @Override public Headers getResponseHeaders() { return responseHeaders; } + @Override public URI getRequestURI() { return uri; } + @Override public String getRequestMethod() { return method; } + @Override public HttpContext getHttpContext() { return from.getHttpContext(); } + @Override public void close() { } + @Override public InputStream getRequestBody() { return in; } + @Override public OutputStream getResponseBody() { return out; } + @Override public void sendResponseHeaders(int rCode, long responseLength) { status = rCode; } + @Override public InetSocketAddress getRemoteAddress() { return from.getRemoteAddress(); } + @Override public int getResponseCode() { return status; } + @Override public InetSocketAddress getLocalAddress() { return from.getLocalAddress(); } + @Override public String getProtocol() { return from.getProtocol(); } + @Override public Object getAttribute(String name) { return from.getAttribute(name); } + @Override public void setAttribute(String name, Object value) { from.setAttribute(name, value); } + @Override public void setStreams(InputStream i, OutputStream o) { } + @Override public HttpPrincipal getPrincipal() { return from.getPrincipal(); } + } + + // ---- JSON-RPC ---- + + private static Map result(Object id, Object result) { + Map m = new LinkedHashMap<>(); + m.put("jsonrpc", "2.0"); + m.put("id", id); + m.put("result", result); + return m; + } + + private static Map error(Object id, int code, String message) { + Map m = new LinkedHashMap<>(); + m.put("jsonrpc", "2.0"); + m.put("id", id); + m.put("error", Map.of("code", code, "message", message)); + return m; + } + + private static void accepted(HttpExchange ex) throws IOException { + ex.sendResponseHeaders(202, -1); + } + + private static void send(HttpExchange ex, int status, Object body) throws IOException { + byte[] bytes = Json.compact(body).getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + ex.getResponseHeaders().set("Cache-Control", "no-store"); + ex.sendResponseHeaders(status, bytes.length); + try (OutputStream o = ex.getResponseBody()) { + o.write(bytes); + } + } + + /** For tests and the agent kit: the tools' names, in order. */ + List toolNames() { + List out = new ArrayList<>(); + for (Tool t : tools) { + out.add(t.name); + } + return out; + } +} diff --git a/src/main/java/com/pointblue/dirxml/web/Serve.java b/src/main/java/com/pointblue/dirxml/web/Serve.java index ff3fa74..4d87a13 100644 --- a/src/main/java/com/pointblue/dirxml/web/Serve.java +++ b/src/main/java/com/pointblue/dirxml/web/Serve.java @@ -66,7 +66,9 @@ public static void main(String[] args) throws Exception { Session.Store sessions = new Session.Store(365L * 24 * 3600); // local mode: the token lives as long as the server Session session = sessions.createWithId(security.token(), Session.Principal.local()); session.workspace = local.id; - server.createContext("/api/", new Api(workspaces, sessions, security, Audit.none(), VERSION)); + Api api = new Api(workspaces, sessions, security, Audit.none(), VERSION); + server.createContext("/api/", api); + server.createContext("/mcp", new Mcp(api, VERSION)); // agents: the same API as MCP tools (docs/agents.md) server.createContext("/", new StaticFiles(web)); start(server); String url = "http://127.0.0.1:" + bound + "/#token=" + security.token(); @@ -118,7 +120,11 @@ static void hosted(Path config) throws Exception { Map agents = h.agents(); Api api = new Api(workspaces, sessions, security, audit, VERSION, agents).withSetup(new SetupService(h, workspaces, audit), new ArchiveService(h, workspaces, audit)) .withRepository(new RepositoryService(h, workspaces, audit)); + if (h.agentTokens) { + api.withAgentTokens(new AgentTokens(h.users, h.dataDir, h.agentTokensMaxDays)); + } server.createContext("/api/", api); + server.createContext("/mcp", new Mcp(api, VERSION)); // agents: the same API as MCP tools (docs/agents.md) server.createContext("/auth/", new Auth(identity, sessions, security, audit, h.publicUrl)); server.createContext("/", new StaticFiles(web)); start(server); diff --git a/src/main/java/com/pointblue/dirxml/web/Session.java b/src/main/java/com/pointblue/dirxml/web/Session.java index 42922d8..eeea32f 100644 --- a/src/main/java/com/pointblue/dirxml/web/Session.java +++ b/src/main/java/com/pointblue/dirxml/web/Session.java @@ -47,6 +47,8 @@ public static Principal local() { public volatile String workspace; /** An agent's session, made for its bearer token: no cookie, so the CSRF check does not apply. */ public volatile boolean bearer; + /** A personal agent token's label when an agent acts for the person (docs/agents.md); null otherwise. */ + public volatile String via; /** Whether the last workspace was already looked for (a failed reopen is not retried on every request). */ volatile boolean restoreTried; diff --git a/src/test/java/com/pointblue/dirxml/web/AgentTokensTest.java b/src/test/java/com/pointblue/dirxml/web/AgentTokensTest.java new file mode 100644 index 0000000..edb2ef3 --- /dev/null +++ b/src/test/java/com/pointblue/dirxml/web/AgentTokensTest.java @@ -0,0 +1,74 @@ +package com.pointblue.dirxml.web; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** Personal agent tokens: made by a person, acting as them, kept as a hash, revocable, following their roles. */ +public class AgentTokensTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static Session.Principal alice(boolean deployer) { + return new Session.Principal("alice", "Alice", "alice@example.com", deployer ? List.of("idm-deployers") : List.of("idm-editors"), true, deployer); + } + + @Test + public void aTokenActsAsItsPersonAndIsKeptOnlyAsAHash() throws Exception { + Path data = tmp.newFolder("data").toPath(); + AgentTokens t = new AgentTokens(new UserData(data), data, 365); + Map made = t.create(alice(false), "claude-laptop", 30); + String token = String.valueOf(made.get("token")); + assertTrue(token.startsWith(AgentTokens.PREFIX)); + AgentTokens.Entry e = t.find(token); + assertNotNull(e); + assertEquals("alice", e.who().subject); + assertEquals("claude-laptop", e.label()); + assertNull("not ours", t.find(AgentTokens.PREFIX + "nope")); + assertNull("not a token at all", t.find("something-else")); + + String file = Files.readString(data.resolve("users/alice/" + AgentTokens.FILE), StandardCharsets.UTF_8); + assertFalse("the token itself is never written", file.contains(token)); + assertTrue(file.contains(AgentTokens.hash(token))); + + // a restart reads them back + AgentTokens again = new AgentTokens(new UserData(data), data, 365); + assertEquals("alice", again.find(token).who().subject); + + // roles follow the person + again.refresh(alice(true)); + assertTrue(again.find(token).who().deployer); + + // one label per person; a life within the server's limit + try { + again.create(alice(true), "claude-laptop", 30); + throw new AssertionError("a second token of the same label was made"); + } catch (TreeService.BadRequest expected) { + // refused + } + try { + new AgentTokens(new UserData(data), data, 30).create(alice(true), "long", 90); + throw new AssertionError("a token past the limit was made"); + } catch (TreeService.BadRequest expected) { + // refused + } + + String id = String.valueOf(made.get("id")); + assertFalse("only its person revokes it", again.revoke(new Session.Principal("bob", "Bob", null, List.of(), true, true), id)); + assertTrue(again.revoke(alice(true), id)); + assertNull(again.find(token)); + assertFalse("the emptied file goes", Files.exists(data.resolve("users/alice/" + AgentTokens.FILE))); + } +} From 9efc136b5182304b60541788bd7447745e4c0f83 Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Thu, 22 Oct 2026 15:14:00 -0500 Subject: [PATCH 2/3] Agents get everything a DirXMLDev install gives them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent kit: AGENTS.md / CLAUDE.md for this install, the dirxml-dev and dirxml-policy-testing skills (each told where its commands are here) and the agent docs; in the image at /opt/idmweb/agent-kit, written to agent/ by setup, downloadable from the page (/api/agent-kit, with the server's MCP URL). - sim and apps in the image beside idm, all three run in the person's workspace (bin/inworkspace). - The page: agent tokens… next to your name: make, list, revoke; the command that connects Claude Code; the kit download. - docs/agents.md; quickstart and hosted-mode; smoke-container checks sim, apps, /mcp and the kit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/image.yml | 7 + CHANGELOG.md | 19 +++ bin/smoke-container.sh | 15 ++ docs/agents.md | 104 +++++++++++++ docs/hosted-mode.md | 4 + docs/quickstart.md | 16 +- hosted/agent-kit/AGENTS.md | 48 ++++++ hosted/agent-kit/CLAUDE.md | 1 + hosted/docker/Dockerfile | 5 +- hosted/docker/bin/apps | 6 + hosted/docker/bin/idm | 24 +-- hosted/docker/bin/inworkspace | 22 +++ hosted/docker/bin/sim | 10 ++ hosted/docker/build.sh | 32 ++++ .../com/pointblue/dirxml/web/AgentKit.java | 73 +++++++++ .../java/com/pointblue/dirxml/web/Api.java | 19 +++ .../com/pointblue/dirxml/web/LocalSetup.java | 9 +- web/src/api.ts | 31 ++++ web/src/components/AgentTokensDialog.tsx | 143 ++++++++++++++++++ web/src/components/RepoBar.tsx | 7 + 20 files changed, 567 insertions(+), 28 deletions(-) create mode 100644 docs/agents.md create mode 100644 hosted/agent-kit/AGENTS.md create mode 100644 hosted/agent-kit/CLAUDE.md create mode 100755 hosted/docker/bin/apps create mode 100644 hosted/docker/bin/inworkspace create mode 100755 hosted/docker/bin/sim create mode 100644 src/main/java/com/pointblue/dirxml/web/AgentKit.java create mode 100644 web/src/components/AgentTokensDialog.tsx diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 6c755e6..625450d 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -40,6 +40,12 @@ jobs: repository: PointBlueTechnology/DirXMLDev ref: v${{ steps.v.outputs.core }} path: DirXMLDev + - name: DirXMLSimulator (its policy-testing skill, for the agent kit) + uses: actions/checkout@v4 + with: + repository: PointBlueTechnology/DirXMLSimulator + ref: v${{ steps.v.outputs.sim }} + path: DirXMLSimulator - name: DirXMLDev and simulator jars env: GH_TOKEN: ${{ github.token }} @@ -70,6 +76,7 @@ jobs: - name: Build and push env: IDM_HOME: ${{ github.workspace }}/DirXMLDev + SIM_HOME: ${{ github.workspace }}/DirXMLSimulator IDM_SIM_VERSION: ${{ steps.v.outputs.sim }} PUSH: "1" ALSO_TAG: latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c6057..8c35da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +Needs DirXMLDev 0.50.0. + +### Added + +- **Agents get everything a DirXMLDev install gives them** (docs/agents.md). + - **MCP:** the server is an MCP server at `/mcp`, with 22 tools: orientation, edit operations + (preview, then apply), commit, compare, compare environments, plan, deploy, rollback, driver + control, simulate, trace. Each tool is a call to the server's own API with the caller's token: + the same sign-in, roles, audit, write lock and production gate as the page. + - **Personal agent tokens:** your name at the top right → *agent tokens…*. An agent with your + token acts as you (your roles, connections, workspace), marked `via` the token in the audit log. + Only a hash is kept; roles follow you; an agent cannot make tokens. The dialog shows the command + that connects Claude Code. `agent.tokens` / `agent.tokens.maxDays` in `hosted.properties`. + - **The agent kit:** this version's `AGENTS.md`, `CLAUDE.md`, the `dirxml-dev` and + `dirxml-policy-testing` skills and the agent docs. In the image; the local install's `setup` + writes it to `agent/`; *Download the agent kit* gives it with the server's MCP address. + - **`sim` and `apps` in the image**, beside `idm`: `docker exec idmweb sim …` (the policy + simulator's CLI), `docker exec idmweb apps …` (Identity Applications), in your workspace. + ## [0.71.0] - 2026-10-20 Needs DirXMLDev 0.49.0. diff --git a/bin/smoke-container.sh b/bin/smoke-container.sh index 2f1353e..3739296 100755 --- a/bin/smoke-container.sh +++ b/bin/smoke-container.sh @@ -82,6 +82,21 @@ s, c = call("POST", "/api/git/commit", {"message": "From the container", "push": check("commit and push reach the host's repository", s == 200 and c["pushed"] and "From the container" in subprocess.run(["git", "--git-dir", os.path.join(work, "repo.git"), "log", "feature/container", "-1", "--format=%s"], capture_output=True, text=True).stdout) cli = subprocess.run(["docker", "exec", name, "idm", "validate", "/var/lib/idmweb/workspaces/repo/agent_ci/feature_container/tree", "--json"], capture_output=True, text=True) check("the idm CLI inside the container validates the same workspace", cli.returncode == 0 and '"ok":true' in cli.stdout.replace(" ", "")) +# agents (docs/agents.md): the simulator's and the Identity Applications' CLIs, the MCP endpoint, the agent kit +sim = subprocess.run(["docker", "exec", name, "sim"], capture_output=True, text=True) +check("the simulator's CLI is in the image", "run" in sim.stdout + sim.stderr and "usage" in (sim.stdout + sim.stderr).lower()) +apps = subprocess.run(["docker", "exec", name, "apps", "--help"], capture_output=True, text=True) +check("the Identity Applications helper is in the image", apps.returncode == 0 and "--env" in apps.stdout) +s, init = call("POST", "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}}) +check("the container answers MCP at /mcp", s == 200 and init["result"]["serverInfo"]["name"] == "dirxmldevweb") +req = urllib.request.Request(base + "/api/agent-kit", headers={"Authorization": "Bearer " + TOKEN}) +with urllib.request.urlopen(req) as r: + import io, zipfile + z = zipfile.ZipFile(io.BytesIO(r.read())) +names = z.namelist() +check("the agent kit carries the rules, both skills and the docs, with this server's MCP address", + "agent-kit/AGENTS.md" in names and "agent-kit/.claude/skills/dirxml-dev/SKILL.md" in names and "agent-kit/.claude/skills/dirxml-policy-testing/SKILL.md" in names + and "agent-kit/docs/agent-guide.md" in names and (base + "/mcp") in z.read("agent-kit/AGENTS.md").decode()) with open(os.path.join(work, "data", "audit.log")) as f: lines = [json.loads(l) for l in f if l.strip()] check("the audit log names the agent", any(l["user"] == "agent:ci" and l["event"] == "commit+push" for l in lines)) diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..28eabc9 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,104 @@ +# Agents: Claude Code, Cursor, scripts + +An agent works with DirXMLDevWeb the way a person does: through the same server, as a person, under +the same rules. Installing DirXMLDevWeb gives an agent everything installing DirXMLDev would: the CLI, +the simulator, the Identity Applications helper, the skills and docs of this version, and an MCP +server. + +## 1. A token + +On the page, your name at the top right → **agent tokens…** → *Make the token*. Give it a label +(`claude-laptop`) and a lifetime (90 days by default). The token is shown once, with the command +that connects Claude Code. + +An agent with your token **acts as you**: your roles (a reader's agent reads, an editor's edits, a +deployer's may deploy), your connections and vault credentials, your workspace of the branch you have +open. Every call it makes is in the audit log as yours, marked `via` the token's label; its commits +are yours. Revoke a token from the same dialog; it stops working at once. An agent cannot make or +revoke tokens. + +Only a SHA-256 of each token is kept (`users//agent-tokens.json` in the server's data folder, +owner-only), with who you were when you last used the page, so a change of your roles reaches your +tokens. On a team server, an administrator can turn personal tokens off (`agent.tokens=false`) or +shorten their longest life (`agent.tokens.maxDays`, default 365) in `hosted.properties`. + +Agents that act for no one (CI) keep their own `agents=` entries in `hosted.properties` +([hosted-mode.md](hosted-mode.md)). + +## 2. MCP + +The server is an MCP server at `/mcp` (Streamable HTTP, stateless). With Claude Code: + +```bash +claude mcp add --transport http dirxmldev http://localhost:8080/mcp --header "Authorization: Bearer " +``` + +Use your server's address for a team server (`https://idm.example.com/mcp`). Keep the token in an +environment variable rather than in a file you commit. + +Every tool is a call to the server's own API, made with your token: the same sign-in, origin check, +roles, audit, per-environment write lock and production gate as the page. + +| Tool | What it does | +|---|---| +| `session` | who you act as, your roles, the project and branch open, the versions | +| `outline` | the tree: drivers, policies, resources, objects, and its revision | +| `read_artifact`, `read_file` | an artifact's content by tree path; a file of the tree | +| `validate` | the engine's own checks over the tree | +| `fishbone` | a driver's policy flow | +| `git_status`, `git_diff` | the workspace against the last commit or the base branch | +| `list_operations` | every edit operation and its arguments | +| `run_operations` | edit the tree: preview (the default), then apply | +| `commit` | commit, and push | +| `environments` | the environments, their tier, whether you are connected | +| `compare` | the tree against an environment's vault | +| `compare_environments` | two environments' vaults side by side | +| `plan_deploy` | a deploy step by step, and what stands in its way | +| `driver_status`, `trace`, `snapshots` | drivers' state, a driver's trace, the snapshots | +| `simulate` | run the project's simulator cases | +| `deploy`, `rollback`, `driver_lifecycle` | vault writes: need the deployer role, and the environment's name typed by the person as `confirm` | + +The server tells the agent the rules when it connects: preview before applying, the plan before any +vault write, the person types the environment's name, production only from `main`, never a secret. + +## 3. The CLIs + +On the machine that runs the container (the local install), the CLIs run in your workspace of the +first project's branch `main`: + +```bash +docker exec idmweb idm validate tree +``` + +```bash +docker exec idmweb sim +``` + +```bash +docker exec idmweb apps --env lab tasks +``` + +`-e IDMWEB_BRANCH=feature/x` picks another branch, `-e IDMWEB_PROJECT=` another project. +Paths are the container's: use paths relative to the workspace (`tree`, `cases`). On Windows, Git +Bash rewrites arguments that look like Unix paths; prefix `MSYS_NO_PATHCONV=1` for an absolute one. + +## 4. The agent kit + +The rules, skills and docs of this version, for any agent that reads `AGENTS.md` or skills: + +- `AGENTS.md` (and `CLAUDE.md`, which imports it): how to connect, and the rules; +- `.claude/skills/dirxml-dev/`: editing, validating, simulating and deploying a driver set; +- `.claude/skills/dirxml-policy-testing/`: testing and debugging policies with the simulator; +- `docs/`: the agent guide, this page, and how work moves between branches and environments. + +The local install's `setup` writes it to `agent/` in your `idmweb` folder, and refreshes it on every +upgrade; start Claude Code there, or copy `agent/.claude/skills` to `~/.claude/skills` to have the +skills everywhere. On any server, *agent tokens…* → *Download the agent kit* gives it as a zip with +that server's MCP address. + +## What stays on a workstation + +A container can't hold what lives on the desktop: Designer, the vendor form builder, the VS Code +extension's views of local files, and macOS Keychain references for secrets (the container keeps +secrets in its own files, environment variables or command references). Install DirXMLDev on the +workstation for those; it works on the same repository. diff --git a/docs/hosted-mode.md b/docs/hosted-mode.md index dae3718..8c4da56 100644 --- a/docs/hosted-mode.md +++ b/docs/hosted-mode.md @@ -98,6 +98,10 @@ are mounted, never shipped. `hosted/docker/compose.yml` runs it behind Caddy (TL **Agents** (CI, an MCP server) use the same server with `Authorization: Bearer ` from `agents=`, or the CLI with `docker exec idmweb idm …`; `bin/smoke-container.sh` proves the image. +**People's agents** (Claude Code, Cursor) use a token the person makes on the page and act as that +person ([agents.md](agents.md)): the server's MCP endpoint is `/mcp`. `agent.tokens=false` turns +personal tokens off; `agent.tokens.maxDays` (default 365) caps how long one may last. + ## A project's repository: moving it to GitHub or GitLab A project made on the page pushes to a repository this server created, `data/projects/.git`. diff --git a/docs/quickstart.md b/docs/quickstart.md index a54cc98..4061f78 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -132,10 +132,18 @@ docker exec idmweb idm validate tree docker exec idmweb idm simulate tree --cases cases ``` -An agent on the same machine (Claude Code, a script, an MCP server) uses the same two doors: -`docker exec idmweb idm …` as a command, or the API at `http://localhost:8080/api/…` with -`Authorization: Bearer ` (the second line of `idmweb.env`). That is every -endpoint the page uses, with the agent's name on each audit line. +The simulator's CLI is `docker exec idmweb sim …` and the Identity Applications helper +`docker exec idmweb apps …`, in the same workspace. + +**Agents** (Claude Code, Cursor, a script) get everything a DirXMLDev install gives them +([agents.md](agents.md)): + +- **MCP:** the server is an MCP server at `http://localhost:8080/mcp`. Make a token on the page + (your name at the top right → *agent tokens…*), which shows the command that connects Claude + Code. The agent acts as you, under your roles. +- **The kit:** `setup` writes this version's rules, skills and docs to `agent/` in the folder: start + Claude Code there, or copy `agent/.claude/skills` into your own. +- **The CLIs** above, and the API at `http://localhost:8080/api/…` with the same token. ## 6. Vaults diff --git a/hosted/agent-kit/AGENTS.md b/hosted/agent-kit/AGENTS.md new file mode 100644 index 0000000..bd44fe6 --- /dev/null +++ b/hosted/agent-kit/AGENTS.md @@ -0,0 +1,48 @@ +# Working on this IDM project with an agent + +This folder is the agent kit of a DirXMLDevWeb {{VERSION}} server: the rules, the skills and the docs +for this version, for Claude Code, Cursor, Codex or any agent that reads `AGENTS.md`. An agent works +through the same server people use, as one of them, under the same rules. + +## Connect + +**MCP (preferred).** The server is an MCP server at `{{MCP_URL}}`. Make a token on the page (your +name at the top right → *Agent tokens…*); the agent then acts as you: your roles, your connections, +your workspace of the branch you have open. For Claude Code: + +``` +claude mcp add --transport http dirxmldev {{MCP_URL}} --header "Authorization: Bearer " +``` + +Keep the token out of chats and files you commit: put it in an environment variable and refer to it. + +**The CLIs, on the machine that runs the container:** `docker exec idmweb idm …` (DirXMLDev), +`docker exec idmweb sim …` (the policy simulator), `docker exec idmweb apps …` (Identity +Applications). They run in your workspace of the first project's branch `main`; +`docker exec -e IDMWEB_BRANCH=feature/x idmweb idm …` picks another branch, +`-e IDMWEB_PROJECT=` another project. Paths are the container's: use paths relative to the +workspace (`tree`, `cases`). Wherever a skill or doc below says `bin/idm`, `bin/sim` or `bin/apps`, +run these. + +## The rules + +1. Orient first: `session`, `outline`, `validate` (or `idm validate tree`). `idm` with no arguments, + or `list_operations`, is the list of what exists; never invent a flag or an operation. +2. Edit the tree with operations (`run_operations`, or `idm tree …`); preview before + applying; validate after. +3. Before any vault write, show the person the plan (`plan_deploy`, or `idm vault.deploy tree --env E + --dry-run`) and wait for a yes. A deploy, rollback or driver start/stop needs the environment's name, + typed by the person in chat. Never fill it in yourself. +4. Production deploys come from `main`, pushed and current. Never work around a refusal: report it. +5. Never print a secret. Tools and the CLI never show one; don't look for them. +6. Commit with a message that says why; never commit secrets, exports or client data. + +## What's here + +- `.claude/skills/dirxml-dev/`: editing, validating, simulating and deploying a driver set + (DirXMLDev). +- `.claude/skills/dirxml-policy-testing/`: testing and debugging policies with the simulator. +- `docs/agent-guide.md`: the edit loop and its refusals, in full. +- `docs/agents.md`: this server for agents: the MCP tools, tokens, what each may do. +- `docs/environment-practices.md` and `docs/git-workflow.md`: how work moves between branches and + environments. diff --git a/hosted/agent-kit/CLAUDE.md b/hosted/agent-kit/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/hosted/agent-kit/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/hosted/docker/Dockerfile b/hosted/docker/Dockerfile index bab072d..7ce0e60 100644 --- a/hosted/docker/Dockerfile +++ b/hosted/docker/Dockerfile @@ -17,9 +17,12 @@ COPY artifacts/dist /opt/idmweb/web/dist COPY artifacts/secrets /opt/idmweb/bin/secrets COPY artifacts/terraform /opt/idmweb/terraform COPY artifacts/THIRD-PARTY.md artifacts/LICENSE /opt/idmweb/ -COPY bin/idm bin/idmweb bin/entrypoint.sh /opt/idmweb/bin/ +COPY artifacts/apps.py /opt/idmweb/bin/apps.py +COPY artifacts/agent-kit /opt/idmweb/agent-kit +COPY bin/idm bin/sim bin/apps bin/inworkspace bin/idmweb bin/entrypoint.sh /opt/idmweb/bin/ RUN chmod +x /opt/idmweb/bin/* /opt/idmweb/bin/secrets/* && mkdir -p /var/lib/idmweb /etc/idmweb /opt/idm/lib \ && chown -R idmweb:idmweb /var/lib/idmweb /etc/idmweb && ln -s /opt/idmweb/bin/idm /usr/local/bin/idm \ + && ln -s /opt/idmweb/bin/sim /usr/local/bin/sim && ln -s /opt/idmweb/bin/apps /usr/local/bin/apps \ # a repository mounted into the container is owned by another uid, and the container may run as # the host's user (the single-user layout): git's rules live in the system config, not a home && git config --system --add safe.directory '*' && git config --system init.defaultBranch main diff --git a/hosted/docker/bin/apps b/hosted/docker/bin/apps new file mode 100755 index 0000000..362c33c --- /dev/null +++ b/hosted/docker/bin/apps @@ -0,0 +1,6 @@ +#!/bin/sh +# The Identity Applications helper inside the container (bin/apps on a workstation): DirXMLDev's +# bin/apps, with the project's environments and your own credentials. `docker exec idmweb apps …`. +set -e +. /opt/idmweb/bin/inworkspace +exec python3 /opt/idmweb/bin/apps.py "$@" diff --git a/hosted/docker/bin/idm b/hosted/docker/bin/idm index 226f809..40292de 100755 --- a/hosted/docker/bin/idm +++ b/hosted/docker/bin/idm @@ -3,28 +3,8 @@ # customer mounted at $IDM_LIB. `docker exec idmweb idm …` is the same command an agent runs # on a workstation. set -e -# The local install (docs/simple-install.md): `docker exec idmweb idm validate tree` runs in your -# workspace of the first project's branch main (IDMWEB_PROJECT and IDMWEB_BRANCH pick another), with -# your own credentials file, when it starts in the image's working directory. Anywhere else (a -# `-w` into a tree, a team server) it runs where it is. -H=/etc/idmweb/hosted.properties -if [ -f "$H" ] && [ "$(pwd)" = /opt/idmweb ]; then - san() { printf '%s' "$1" | sed 's#[^A-Za-z0-9._-]#_#g'; } - U=$(sed -n 's/^token\.user=//p' "$H" | head -1) - if [ -n "$U" ]; then - S=$(san "$U") - MINE="/var/lib/idmweb/users/$S/environments.properties" - if [ -z "${IDM_CREDENTIALS:-}" ] && [ -f "$MINE" ]; then export IDM_CREDENTIALS="$MINE"; fi - P=${IDMWEB_PROJECT:-$(sed -n 's/^projects=//p' "$H" | head -1 | cut -d, -f1)} - B=${IDMWEB_BRANCH:-main} - if [ -n "$P" ]; then - W="/var/lib/idmweb/workspaces/$(san "$P")/$S/$(san "$B")" - if [ -d "$W" ]; then cd "$W"; else echo "note: no workspace for $P/$B yet (open the branch on the page); running outside one" >&2; cd /var/lib/idmweb; fi - else - cd /var/lib/idmweb - fi - fi -fi +# In your workspace (inworkspace says how), then the CLI. +. /opt/idmweb/bin/inworkspace IDM_HOME=${IDM_HOME:-/opt/idmweb} IDM_LIB=${IDM_LIB:-/opt/idm/lib} CP="$IDM_HOME/lib/dirxml-dev.jar:$IDM_HOME/lib/dirxml-simulator.jar:$IDM_HOME/lib/jldap.jar:$IDM_HOME/lib/dirxml-trace-core.jar:$IDM_HOME/lib/postgresql.jar" diff --git a/hosted/docker/bin/inworkspace b/hosted/docker/bin/inworkspace new file mode 100644 index 0000000..2d93c5b --- /dev/null +++ b/hosted/docker/bin/inworkspace @@ -0,0 +1,22 @@ +# Sourced by idm, sim and apps: the local install (docs/simple-install.md) runs them in your workspace +# of the first project's branch main (IDMWEB_PROJECT and IDMWEB_BRANCH pick another), with your own +# credentials file, when they start in the image's working directory. Anywhere else (a `-w` into a +# tree, a team server) they run where they are. +H=/etc/idmweb/hosted.properties +if [ -f "$H" ] && [ "$(pwd)" = /opt/idmweb ]; then + san() { printf '%s' "$1" | sed 's#[^A-Za-z0-9._-]#_#g'; } + U=$(sed -n 's/^token\.user=//p' "$H" | head -1) + if [ -n "$U" ]; then + S=$(san "$U") + MINE="/var/lib/idmweb/users/$S/environments.properties" + if [ -z "${IDM_CREDENTIALS:-}" ] && [ -f "$MINE" ]; then export IDM_CREDENTIALS="$MINE"; fi + P=${IDMWEB_PROJECT:-$(sed -n 's/^projects=//p' "$H" | head -1 | cut -d, -f1)} + B=${IDMWEB_BRANCH:-main} + if [ -n "$P" ]; then + W="/var/lib/idmweb/workspaces/$(san "$P")/$S/$(san "$B")" + if [ -d "$W" ]; then cd "$W"; else echo "note: no workspace for $P/$B yet (open the branch on the page); running outside one" >&2; cd /var/lib/idmweb; fi + else + cd /var/lib/idmweb + fi + fi +fi diff --git a/hosted/docker/bin/sim b/hosted/docker/bin/sim new file mode 100755 index 0000000..7f983c8 --- /dev/null +++ b/hosted/docker/bin/sim @@ -0,0 +1,10 @@ +#!/bin/sh +# The DirXML Policy Simulator's CLI inside the container (bin/sim on a workstation): the simulator's jar +# and the engine jars the customer mounted at $IDM_LIB. `docker exec idmweb sim …`. +set -e +. /opt/idmweb/bin/inworkspace +IDM_HOME=${IDM_HOME:-/opt/idmweb} +IDM_LIB=${IDM_LIB:-/opt/idm/lib} +CP="$IDM_HOME/lib/dirxml-simulator.jar" +for j in "$IDM_LIB"/*.jar; do [ -f "$j" ] && CP="$CP:$j"; done +exec java ${SIM_JAVA_OPTS:-} -cp "$CP" com.pointblue.dirxml.sim.Cli "$@" diff --git a/hosted/docker/build.sh b/hosted/docker/build.sh index 0ffc9ff..a64c321 100755 --- a/hosted/docker/build.sh +++ b/hosted/docker/build.sh @@ -42,6 +42,38 @@ cp -R "$IDM_HOME/bin/secrets" "$CTX/artifacts/secrets" cp -R "$IDM_HOME/terraform" "$CTX/artifacts/terraform" find "$CTX/artifacts/terraform" -name .terraform -prune -exec rm -rf {} + 2>/dev/null || true find "$CTX/artifacts/terraform" \( -name '*.tfstate*' -o -name '.terraform.lock.hcl' \) -delete 2>/dev/null || true +# bin/apps (the Identity Applications helper), behind the image's apps launcher +cp "$IDM_HOME/bin/apps" "$CTX/artifacts/apps.py" +# the agent kit (docs/agents.md): this version's rules, skills and docs for an agent working with the server +SIM_HOME=${SIM_HOME:-"$HERE/../DirXMLSimulator"} +KIT="$CTX/artifacts/agent-kit" +mkdir -p "$KIT/.claude/skills" "$KIT/docs" +cp "$HERE/hosted/agent-kit/AGENTS.md" "$HERE/hosted/agent-kit/CLAUDE.md" "$KIT/" +cp -R "$IDM_HOME/.claude/skills/dirxml-dev" "$KIT/.claude/skills/" +if [ -d "$SIM_HOME/.claude/skills/dirxml-policy-testing" ]; then + cp -R "$SIM_HOME/.claude/skills/dirxml-policy-testing" "$KIT/.claude/skills/" +else + echo "note: no DirXMLSimulator checkout at $SIM_HOME: the agent kit has no simulator skill" >&2 +fi +cp "$IDM_HOME/docs/agent-guide.md" "$KIT/docs/" +cp "$HERE/docs/agents.md" "$HERE/docs/environment-practices.md" "$HERE/docs/git-workflow.md" "$KIT/docs/" +# each skill says, under its front matter, where its commands are in this install +for s in "$KIT"/.claude/skills/*/SKILL.md; do + python3 - "$s" <<'PY' +import sys +p = sys.argv[1] +t = open(p, encoding="utf-8").read() +note = ("\n> **In a DirXMLDevWeb install:** on the machine that runs the container, `bin/idm` is `docker exec idmweb idm`, " + "`bin/sim` is `docker exec idmweb sim` and `bin/apps` is `docker exec idmweb apps`, run in your workspace; " + "or use the `dirxmldev` MCP tools. The kit's AGENTS.md has the rules and how to connect.\n") +if t.startswith("---"): + end = t.index("\n---", 3) + 4 + t = t[:end] + "\n" + note + t[end:] +else: + t = note + "\n" + t +open(p, "w", encoding="utf-8").write(t) +PY +done # the engine's jars are NetIQ's: refuse to build if any reached the context (a class from one would be # com/novell/nds/dirxml/… or com/netiq/…; DirXMLDev's own jar is com/pointblue/…) for j in "$CTX"/artifacts/*.jar; do diff --git a/src/main/java/com/pointblue/dirxml/web/AgentKit.java b/src/main/java/com/pointblue/dirxml/web/AgentKit.java new file mode 100644 index 0000000..5eae439 --- /dev/null +++ b/src/main/java/com/pointblue/dirxml/web/AgentKit.java @@ -0,0 +1,73 @@ +package com.pointblue.dirxml.web; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * The agent kit (docs/agents.md): this version's AGENTS.md, CLAUDE.md, skills and docs for an agent working with the + * server, with the server's version and MCP address filled in. The image carries it at + * {@code /opt/idmweb/agent-kit}; {@code IDMWEB_AGENT_KIT} names another folder (a checkout's {@code hosted/agent-kit} + * when developing). + */ +final class AgentKit { + + private AgentKit() { + } + + /** The kit's folder, or null when this server has none. */ + static Path locate() { + for (String c : new String[] {System.getenv("IDMWEB_AGENT_KIT"), + System.getenv("IDM_HOME") == null ? null : System.getenv("IDM_HOME") + "/agent-kit", "/opt/idmweb/agent-kit"}) { + if (c != null && Files.isDirectory(Path.of(c))) { + return Path.of(c); + } + } + return null; + } + + /** A text file's content with the placeholders filled; other files as they are. */ + static byte[] content(Path file, String version, String mcpUrl) throws IOException { + byte[] raw = Files.readAllBytes(file); + String name = file.getFileName().toString(); + if (!name.endsWith(".md") && !name.endsWith(".txt")) { + return raw; + } + return new String(raw, StandardCharsets.UTF_8).replace("{{VERSION}}", version).replace("{{MCP_URL}}", mcpUrl).getBytes(StandardCharsets.UTF_8); + } + + static List files(Path kit) throws IOException { + try (Stream s = Files.walk(kit)) { + return s.filter(Files::isRegularFile).sorted().toList(); + } + } + + /** The kit as a zip, its files under {@code agent-kit/}. */ + static void zip(Path kit, String version, String mcpUrl, OutputStream out) throws IOException { + try (ZipOutputStream z = new ZipOutputStream(out)) { + for (Path f : files(kit)) { + z.putNextEntry(new ZipEntry("agent-kit/" + kit.relativize(f).toString().replace('\\', '/'))); + z.write(content(f, version, mcpUrl)); + z.closeEntry(); + } + } + } + + /** The kit written into {@code target} (setup's {@code agent/}), replacing what an earlier version wrote there. */ + static int write(Path kit, Path target, String version, String mcpUrl) throws IOException { + int n = 0; + for (Path f : files(kit)) { + Path to = target.resolve(kit.relativize(f).toString()); + Files.createDirectories(to.getParent()); + Files.write(to, content(f, version, mcpUrl)); + n++; + } + return n; + } +} diff --git a/src/main/java/com/pointblue/dirxml/web/Api.java b/src/main/java/com/pointblue/dirxml/web/Api.java index d4a3c67..8b23537 100644 --- a/src/main/java/com/pointblue/dirxml/web/Api.java +++ b/src/main/java/com/pointblue/dirxml/web/Api.java @@ -204,6 +204,25 @@ public void handle(HttpExchange ex) throws IOException { return; } } + if (path.equals("/api/agent-kit") && method.equals("GET")) { + // the agent kit: this version's rules, skills and docs, with this server's MCP address + java.nio.file.Path kit = AgentKit.locate(); + if (kit == null) { + throw new TreeService.NotFound("this server has no agent kit (the image carries it; IDMWEB_AGENT_KIT names a folder)"); + } + String host = ex.getRequestHeaders().getFirst("Host"); + String proto = ex.getRequestHeaders().getFirst("X-Forwarded-Proto"); + String mcpUrl = (proto == null ? "http" : proto) + "://" + (host == null ? "localhost:8080" : host) + "/mcp"; + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + AgentKit.zip(kit, version, mcpUrl, bytes); + ex.getResponseHeaders().set("Content-Type", "application/zip"); + ex.getResponseHeaders().set("Content-Disposition", "attachment; filename=\"dirxmldevweb-agent-kit-" + version + ".zip\""); + ex.sendResponseHeaders(200, bytes.size()); + try (OutputStream out = ex.getResponseBody()) { + bytes.writeTo(out); + } + return; + } if (path.startsWith("/api/agent-tokens")) { // anyone signed in may make tokens: an agent does no more than its person may send(ex, 200, agentTokensRoute(session, method, path, ex)); diff --git a/src/main/java/com/pointblue/dirxml/web/LocalSetup.java b/src/main/java/com/pointblue/dirxml/web/LocalSetup.java index c587f86..2b1a447 100644 --- a/src/main/java/com/pointblue/dirxml/web/LocalSetup.java +++ b/src/main/java/com/pointblue/dirxml/web/LocalSetup.java @@ -198,6 +198,9 @@ static void run(Options o, PrintStream out) throws IOException, InterruptedExcep } Files.writeString(dir.resolve(".env"), dotEnv.toString(), StandardCharsets.UTF_8); String proj = projectOf(Files.readString(hostedProps, StandardCharsets.UTF_8)); + // the agent kit (docs/agents.md): this version's rules, skills and docs, refreshed by every setup + Path kit = AgentKit.locate(); + int kitFiles = kit == null ? 0 : AgentKit.write(kit, dir.resolve("agent"), Serve.VERSION, "http://localhost:" + o.port + "/mcp"); out.println(); out.println("DirXMLDevWeb is set up in the folder you mounted at " + dir + " (image " + image(o) + ", port " + o.port + ")."); @@ -211,7 +214,11 @@ static void run(Options o, PrintStream out) throws IOException, InterruptedExcep } else { out.println("No tree yet: the page asks where it comes from (a vault, a Designer project or export, a repository, a remote)."); } - out.println("The CLI: docker exec idmweb idm validate tree"); + out.println("The CLI: docker exec idmweb idm validate tree (also: sim, apps)"); + if (kitFiles > 0) { + out.println("Agents: agent/ holds the rules, skills and docs (start Claude Code there, or copy agent/.claude/skills to your own);"); + out.println(" MCP at http://localhost:" + o.port + "/mcp with a token from the page (your name at the top right > Agent tokens...)"); + } out.println("Engine jars: optional (validate's compile check and the simulator): copy them into lib/, then docker restart idmweb"); out.println("Your vault password goes in " + dir.relativize(mine) + " (the page's Connections view writes it)."); out.println("Stop: docker compose -f /compose.yml down"); diff --git a/web/src/api.ts b/web/src/api.ts index 008bbf4..5aec058 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -90,7 +90,38 @@ export const GIT_CHANGED = "idmweb:git-changed"; const q = encodeURIComponent; +/** A person's token for an agent (docs/agents.md): the value only ever in the answer that made it. */ +export interface AgentToken { + id: string; + label: string; + created: string; + expires: string; + lastUsed: string | null; + expired: boolean; + token?: string; +} + export const api = { + agentTokens: { + list: () => call<{ maxDays: number; defaultDays: number; tokens: AgentToken[]; mcp: string }>("GET", "/api/agent-tokens"), + create: (label: string, days: number) => call("POST", "/api/agent-tokens", { label, days }), + revoke: (id: string) => call<{ revoked: boolean }>("POST", "/api/agent-tokens/revoke", { id }), + /** The agent kit: this version's rules, skills and docs, with this server's MCP address. */ + kit: async (): Promise<{ blob: Blob; fileName: string }> => { + const res = await fetch("/api/agent-kit", { headers: { "X-IDMWeb-Token": token() } }); + if (!res.ok) { + let message = res.statusText; + try { + message = ((await res.json()) as { error?: string }).error ?? message; + } catch { + // not JSON + } + throw new Error(message); + } + const m = /filename="([^"]+)"/.exec(res.headers.get("Content-Disposition") ?? ""); + return { blob: await res.blob(), fileName: m ? m[1]! : "agent-kit.zip" }; + }, + }, session: () => call("GET", "/api/session"), authHeaders, outline: () => call("GET", "/api/outline"), diff --git a/web/src/components/AgentTokensDialog.tsx b/web/src/components/AgentTokensDialog.tsx new file mode 100644 index 0000000..86adc7d --- /dev/null +++ b/web/src/components/AgentTokensDialog.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { api, type AgentToken } from "../api"; + +/** + * A person's tokens for their agents (docs/agents.md): an agent with one acts as them, through the MCP endpoint or the + * API, under their roles. A token's value is shown once, with the command that connects Claude Code; afterwards only its + * label, dates and last use. The agent kit (rules, skills, docs for this version) downloads from here too. + */ +export function AgentTokensDialog({ onClose }: { onClose: () => void }) { + const [tokens, setTokens] = useState(null); + const [limits, setLimits] = useState({ maxDays: 365, defaultDays: 90 }); + const [label, setLabel] = useState("claude"); + const [days, setDays] = useState(90); + const [made, setMade] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const mcpUrl = `${window.location.origin}/mcp`; + const load = () => + api.agentTokens + .list() + .then((r) => (setTokens(r.tokens), setLimits({ maxDays: r.maxDays, defaultDays: r.defaultDays }), setDays((d) => Math.min(d, r.maxDays)))) + .catch((e: Error) => setError(e.message)); + useEffect(() => { + void load(); + }, []); + const create = () => { + setBusy(true); + setError(null); + api.agentTokens + .create(label.trim(), days) + .then((t) => (setMade(t), void load())) + .catch((e: Error) => setError(e.message)) + .finally(() => setBusy(false)); + }; + const revoke = (t: AgentToken) => { + if (!window.confirm(`Revoke "${t.label}"? An agent using it stops working at once.`)) { + return; + } + api.agentTokens + .revoke(t.id) + .then(() => load()) + .catch((e: Error) => setError(e.message)); + }; + const kit = () => + api.agentTokens + .kit() + .then(({ blob, fileName }) => { + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = fileName; + a.click(); + URL.revokeObjectURL(a.href); + }) + .catch((e: Error) => setError(e.message)); + const command = made?.token ? `claude mcp add --transport http dirxmldev ${mcpUrl} --header "Authorization: Bearer ${made.token}"` : ""; + return ( +
+
+
+

Agent tokens

+ An agent with one of your tokens acts as you: your roles, your connections, your workspace. The audit log names the token. +
+
+ {made?.token && ( +
+ "{made.label}" is made. Copy it now: it is not shown again. +
{made.token}
+

Connect Claude Code (keep the token out of files you commit):

+
{command}
+ +
+ )} +

Make one

+
+ + + +
+

Yours

+ {tokens === null ? ( +

…

+ ) : tokens.length === 0 ? ( +

None yet.

+ ) : ( + + + + + + + + + + + {tokens.map((t) => ( + + + + + + + + ))} + +
LabelMadeExpiresLast used +
{t.label}{t.created.slice(0, 10)}{t.expires.slice(0, 10)}{t.expired ? " (expired)" : ""}{t.lastUsed ? t.lastUsed.slice(0, 16).replace("T", " ") : "never"} + +
+ )} +

The agent kit

+

+ This version's rules (AGENTS.md), skills and docs, with this server's MCP address ({mcpUrl}). Unzip it where you start your + agent, or copy its .claude/skills into your own. +

+ + {error &&

{error}

} +
+
+ + +
+
+
+ ); +} diff --git a/web/src/components/RepoBar.tsx b/web/src/components/RepoBar.tsx index e0958f5..6991182 100644 --- a/web/src/components/RepoBar.tsx +++ b/web/src/components/RepoBar.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { GIT_CHANGED, api } from "../api"; +import { AgentTokensDialog } from "./AgentTokensDialog"; import type { GitStatus, PullRequestInfo, Selection, Session, WorkspacesInfo } from "../types"; import { ConflictDialog } from "./ConflictDialog"; import { MergeDialog } from "./MergeDialog"; @@ -28,6 +29,7 @@ export function RepoBar({ session, revision, onWorkspaceChanged, onBusy, onAddPr return () => window.removeEventListener("idmweb:suggest-commit", on); }, []); const [busy, setBusy] = useState(null); + const [tokensOpen, setTokensOpen] = useState(false); const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); const [branch, setBranch] = useState(""); const [open, setOpen] = useState(false); @@ -343,10 +345,15 @@ export function RepoBar({ session, revision, onWorkspaceChanged, onBusy, onAddPr /> )} + {tokensOpen && setTokensOpen(false)} />} {hosted && ( {session.user.name} · {session.user.deployer ? "deployer" : session.user.editor ? "editor" : "read-only"} ·{" "} + {" "} + ·{" "} sign out From ed0c0d664243e9e6143a7951484410b70eedbcca Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Thu, 22 Oct 2026 15:52:52 -0500 Subject: [PATCH 3/3] MCP resources and prompts: the agent kit over MCP, a setup prompt; release 0.72.0 The kit is served as dirxmldev://kit/ resources (version and MCP address filled in), with prompts setup (rules, skills, docs, orient) and orient. The token dialog gives the setup text to paste into agents without MCP prompts; it holds no token. Pins DirXMLDev 0.50.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- bin/smoke-container.sh | 4 + bin/smoke-hosted.sh | 16 +++- docs/agents.md | 20 ++++ hosted/agent-kit/AGENTS.md | 4 + pom.xml | 4 +- .../java/com/pointblue/dirxml/web/Api.java | 2 + .../java/com/pointblue/dirxml/web/Mcp.java | 94 ++++++++++++++++++- .../java/com/pointblue/dirxml/web/Serve.java | 2 +- web/src/api.ts | 2 +- web/src/components/AgentTokensDialog.tsx | 17 +++- 11 files changed, 162 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c35da4..789e0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to DirXMLDevWeb are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.72.0] - 2026-10-22 Needs DirXMLDev 0.50.0. @@ -20,6 +20,10 @@ Needs DirXMLDev 0.50.0. token acts as you (your roles, connections, workspace), marked `via` the token in the audit log. Only a hash is kept; roles follow you; an agent cannot make tokens. The dialog shows the command that connects Claude Code. `agent.tokens` / `agent.tokens.maxDays` in `hosted.properties`. + - **Discoverable over MCP:** the agent kit is served as MCP resources (`dirxmldev://kit/…`), and + two prompts: `setup` (read the rules, install the skills, orient; `/mcp__dirxmldev__setup` in + Claude Code) and `orient`. The token dialog's *Set up your agent* gives the same text to paste + into agents without MCP prompts; it holds no token. - **The agent kit:** this version's `AGENTS.md`, `CLAUDE.md`, the `dirxml-dev` and `dirxml-policy-testing` skills and the agent docs. In the image; the local install's `setup` writes it to `agent/`; *Download the agent kit* gives it with the server's MCP address. diff --git a/bin/smoke-container.sh b/bin/smoke-container.sh index 3739296..56637ea 100755 --- a/bin/smoke-container.sh +++ b/bin/smoke-container.sh @@ -89,6 +89,10 @@ apps = subprocess.run(["docker", "exec", name, "apps", "--help"], capture_output check("the Identity Applications helper is in the image", apps.returncode == 0 and "--env" in apps.stdout) s, init = call("POST", "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}}) check("the container answers MCP at /mcp", s == 200 and init["result"]["serverInfo"]["name"] == "dirxmldevweb") +s, rl = call("POST", "/mcp", {"jsonrpc": "2.0", "id": 2, "method": "resources/list"}) +uris = [x["uri"] for x in rl["result"]["resources"]] +check("its MCP resources are the kit: the rules and both skills", "dirxmldev://kit/AGENTS.md" in uris and "dirxmldev://kit/.claude/skills/dirxml-dev/SKILL.md" in uris + and "dirxmldev://kit/.claude/skills/dirxml-policy-testing/SKILL.md" in uris) req = urllib.request.Request(base + "/api/agent-kit", headers={"Authorization": "Bearer " + TOKEN}) with urllib.request.urlopen(req) as r: import io, zipfile diff --git a/bin/smoke-hosted.sh b/bin/smoke-hosted.sh index bc0a195..c38bb67 100755 --- a/bin/smoke-hosted.sh +++ b/bin/smoke-hosted.sh @@ -42,7 +42,7 @@ agents=ci:idm-deployers;reader: agent.ci.token=ci-agent-token-0123456789 agent.reader.token=reader-agent-token-0123456789 ENV -IDM_ENVIRONMENTS="$WORK/data/environments.properties" "$HERE/bin/idmweb" serve --hosted "$WORK/hosted.properties" > "$WORK/log" 2>&1 & +IDMWEB_AGENT_KIT="$HERE/hosted/agent-kit" IDM_ENVIRONMENTS="$WORK/data/environments.properties" "$HERE/bin/idmweb" serve --hosted "$WORK/hosted.properties" > "$WORK/log" 2>&1 & PID=$! i=0 while ! grep -q 'hosted:' "$WORK/log" 2>/dev/null; do @@ -287,6 +287,20 @@ check("MCP: another host is refused", s == 403) s, _, _ = anon.call("POST", "/mcp", {"jsonrpc": "2.0", "id": 7, "method": "tools/list"}, csrf=False) check("MCP: without a token it says to sign in", s == 401) check("MCP: GET has no stream (405)", ci.call("GET", "/mcp", headers=mine)[0] == 405) +s, rl, _ = mcp({"jsonrpc": "2.0", "id": 8, "method": "resources/list"}) +uris = [x["uri"] for x in rl["result"]["resources"]] +check("MCP: the agent kit is listed as resources", s == 200 and "dirxmldev://kit/AGENTS.md" in uris) +s, rr, _ = mcp({"jsonrpc": "2.0", "id": 9, "method": "resources/read", "params": {"uri": "dirxmldev://kit/AGENTS.md"}}) +check("MCP: a resource reads with this server's MCP address filled in", s == 200 and (base + "/mcp") in rr["result"]["contents"][0]["text"] and "{{" not in rr["result"]["contents"][0]["text"]) +s, bad, _ = mcp({"jsonrpc": "2.0", "id": 10, "method": "resources/read", "params": {"uri": "dirxmldev://kit/../../hosted.properties"}}) +check("MCP: a resource outside the kit is refused", s == 200 and "error" in bad) +s, pl, _ = mcp({"jsonrpc": "2.0", "id": 11, "method": "prompts/list"}) +check("MCP: the setup and orient prompts are listed", s == 200 and [p["name"] for p in pl["result"]["prompts"]] == ["setup", "orient"]) +s, pg, _ = mcp({"jsonrpc": "2.0", "id": 12, "method": "prompts/get", "params": {"name": "setup", "arguments": {"skills_dir": ".claude/skills"}}}) +st = pg["result"]["messages"][0]["content"]["text"] +check("MCP: the setup prompt reads the rules, installs the skills where asked, then orients", s == 200 and "dirxmldev://kit/AGENTS.md" in st and ".claude/skills/com.pointblue.dirxml dirxml-dev-web - 0.71.0 + 0.72.0 MIT License @@ -23,7 +23,7 @@ - 0.49.0 + 0.50.0 1.7.0