mcp-ado-browser 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Structured error taxonomy. Every tool failure maps to one of these so the MCP
3
+ * layer can emit a clean, machine-readable error instead of a stack trace.
4
+ *
5
+ * Sentinel strings (ADO_AUTH_EXPIRED, ADO_HTTP_xxx) are thrown deep in the
6
+ * transport (inside page.evaluate) where only strings survive serialization;
7
+ * they are re-hydrated into these classes at the transport boundary.
8
+ */
9
+ export class AdoError extends Error {
10
+ code;
11
+ details;
12
+ constructor(code, message, details) {
13
+ super(message);
14
+ this.name = "AdoError";
15
+ this.code = code;
16
+ this.details = details;
17
+ }
18
+ toJSON() {
19
+ return { code: this.code, message: this.message, ...(this.details ? { details: this.details } : {}) };
20
+ }
21
+ }
22
+ /** Session cookie is dead / expired. The agent must re-run `authenticate`. */
23
+ export class AuthRequiredError extends AdoError {
24
+ constructor(url) {
25
+ super("AUTH_REQUIRED", "AUTH_REQUIRED: not signed in to Azure DevOps. Call the `authenticate` tool (it opens a browser for interactive sign-in), then retry this request.", url ? { url } : undefined);
26
+ this.name = "AuthRequiredError";
27
+ }
28
+ }
29
+ /** A requested id/resource does not exist. */
30
+ export class NotFoundError extends AdoError {
31
+ constructor(resource, id, url) {
32
+ super("NOT_FOUND", `NOT_FOUND: ${resource} '${id}' does not exist`, { resource, id, ...(url ? { url } : {}) });
33
+ this.name = "NotFoundError";
34
+ }
35
+ }
36
+ /** Any other non-2xx HTTP response. */
37
+ export class HttpError extends AdoError {
38
+ status;
39
+ constructor(status, url, body) {
40
+ super("HTTP_ERROR", `ADO_HTTP_${status}: ${url}`, { status, url, ...(body ? { body: body.slice(0, 500) } : {}) });
41
+ this.name = "HttpError";
42
+ this.status = status;
43
+ }
44
+ }
45
+ /** Output JSON did not match its declared zod schema (schema drift). */
46
+ export class ValidationError extends AdoError {
47
+ constructor(message, issues) {
48
+ super("VALIDATION_ERROR", `VALIDATION_ERROR: ${message}`, issues ? { issues } : undefined);
49
+ this.name = "ValidationError";
50
+ }
51
+ }
52
+ /** A feature that was empirically proven unavailable via the browser session. */
53
+ export class EmpiricallyBlockedError extends AdoError {
54
+ constructor(message, evidence) {
55
+ super("EMPIRICALLY_BLOCKED", `EMPIRICALLY_BLOCKED: ${message}`, evidence);
56
+ this.name = "EmpiricallyBlockedError";
57
+ }
58
+ }
59
+ export class ConfigError extends AdoError {
60
+ constructor(message) {
61
+ super("CONFIG_ERROR", `CONFIG_ERROR: ${message}`);
62
+ this.name = "ConfigError";
63
+ }
64
+ }
65
+ /** Sentinel thrown from inside page.evaluate (only strings survive there). */
66
+ export const SENTINEL = {
67
+ authExpired: "ADO_AUTH_EXPIRED",
68
+ httpPrefix: "ADO_HTTP_", // followed by `${status}:${url}`
69
+ };
70
+ /** Re-hydrate a sentinel string thrown across the page.evaluate boundary. */
71
+ export function rehydrateSentinel(err, fallbackUrl) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ if (msg.includes(SENTINEL.authExpired))
74
+ return new AuthRequiredError(fallbackUrl);
75
+ const m = msg.match(/ADO_HTTP_(\d+):(.*)$/s);
76
+ if (m) {
77
+ const status = Number(m[1]);
78
+ const url = m[2] || fallbackUrl || "";
79
+ if (status === 404)
80
+ return new NotFoundError("resource", url, url);
81
+ if (status === 401 || status === 403)
82
+ return new AuthRequiredError(url);
83
+ return new HttpError(status, url);
84
+ }
85
+ if (err instanceof AdoError)
86
+ return err;
87
+ return new AdoError("INTERNAL", msg);
88
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Single binary entry point (mission §1: one package, one npx binary).
4
+ *
5
+ * npx mcp-ado-browser -> start the MCP stdio server (9 tools)
6
+ * npx mcp-ado-browser authenticate -> open a VISIBLE browser for interactive
7
+ * (re)auth on the isolated profile, persist
8
+ * the session, then re-validate headless.
9
+ *
10
+ * The authenticate subcommand is the "authenticate tool/mechanism" of mission §3,
11
+ * kept out of tools/list so that list stays at exactly the 9 data tools (§7).
12
+ */
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { loadConfig } from "./config.js";
15
+ import { createMcpServer } from "./server.js";
16
+ import { AdoRuntime, runAuthenticate, runLogout, runStatus } from "./runtime.js";
17
+ import { applyArgs, parseArgs } from "./cli.js";
18
+ import { log } from "./logger.js";
19
+ const HELP = `mcp-ado-browser — read-only Azure DevOps for MCP, via your browser session (no PAT)
20
+
21
+ Usage:
22
+ npx mcp-ado-browser [--org <org>] [--project <project>] start the MCP stdio server
23
+ npx mcp-ado-browser authenticate --org <org> interactive sign-in (visible browser)
24
+ npx mcp-ado-browser status --org <org> show identity + whether the session is valid
25
+ npx mcp-ado-browser logout clear the persisted session and cache
26
+
27
+ Options (also settable via env, shown in []):
28
+ --org <org> [ADO_ORG] organization (required)
29
+ --project <project> [ADO_PROJECT] default project scope (optional; org-wide otherwise)
30
+ --channel <chrome|msedge> [ADO_BROWSER_CHANNEL] installed browser to drive (default chrome)
31
+ --user-data-dir <path> [ADO_USER_DATA_DIR] isolated persistent profile dir
32
+ --cache-ttl <seconds> [ADO_CACHE_TTL_SECONDS] cache TTL (default 900)
33
+ --api-version <v> [ADO_API_VERSION] force an api-version (else discovery/defaults)
34
+ --no-app-window [ADO_APP_WINDOW=0] normal browser window for auth (not chromeless)
35
+ --headed [ADO_HEADLESS=0] run work with a visible window
36
+ `;
37
+ async function main() {
38
+ const parsed = parseArgs(process.argv.slice(2));
39
+ applyArgs(parsed); // CLI flags -> process.env (precedence), before loadConfig
40
+ const sub = parsed.command;
41
+ if (sub === "authenticate" || sub === "auth") {
42
+ process.exitCode = await runAuthenticate();
43
+ return;
44
+ }
45
+ if (sub === "logout") {
46
+ process.exitCode = await runLogout();
47
+ return;
48
+ }
49
+ if (sub === "status" || sub === "whoami") {
50
+ process.exitCode = await runStatus();
51
+ return;
52
+ }
53
+ if (sub === "help" || process.argv.includes("--help") || process.argv.includes("-h")) {
54
+ process.stdout.write(HELP);
55
+ return;
56
+ }
57
+ // Default: MCP stdio server. The browser session is built lazily on first use so
58
+ // `initialize`/`tools/list` work without a browser, and `authenticate` can open a
59
+ // visible window from inside the running server.
60
+ const cfg = loadConfig();
61
+ const runtime = new AdoRuntime(cfg);
62
+ const server = createMcpServer({
63
+ getClient: () => runtime.getClient(),
64
+ authenticate: (timeoutSeconds) => runtime.authenticate(timeoutSeconds * 1000),
65
+ });
66
+ const transport = new StdioServerTransport();
67
+ await server.connect(transport);
68
+ log.info("MCP stdio server ready (mcp-ado-browser).");
69
+ }
70
+ main().catch((e) => {
71
+ log.error(`fatal: ${String(e)}`);
72
+ process.exit(1);
73
+ });
@@ -0,0 +1,22 @@
1
+ const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
2
+ const threshold = LEVELS[process.env.ADO_LOG_LEVEL ?? "info"] ?? LEVELS.info;
3
+ function emit(level, msg, extra) {
4
+ if (LEVELS[level] < threshold)
5
+ return;
6
+ const line = extra === undefined ? `[${level}] ${msg}` : `[${level}] ${msg} ${safe(extra)}`;
7
+ process.stderr.write(line + "\n");
8
+ }
9
+ function safe(v) {
10
+ try {
11
+ return typeof v === "string" ? v : JSON.stringify(v);
12
+ }
13
+ catch {
14
+ return String(v);
15
+ }
16
+ }
17
+ export const log = {
18
+ debug: (m, e) => emit("debug", m, e),
19
+ info: (m, e) => emit("info", m, e),
20
+ warn: (m, e) => emit("warn", m, e),
21
+ error: (m, e) => emit("error", m, e),
22
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * MockAdoServer — a deterministic replay/route server over node:http.
3
+ *
4
+ * Keys routes by the *real* ADO host (forwarded by MockTransport in
5
+ * `x-ado-real-host`) + method + pathname, so all 5 ADO hosts share one port.
6
+ *
7
+ * It also ENFORCES the mandatory `X-TFS-FedAuthRedirect: Suppress` header: a
8
+ * request missing it gets 400. That turns mission §1's "header obligatoire" into
9
+ * a positive, testable assertion rather than a convention.
10
+ *
11
+ * Handlers are dynamic (not just static fixtures) so freshness tests can mutate
12
+ * the returned `System.Rev` between calls, and auth-expiry can be toggled live.
13
+ */
14
+ import * as http from "node:http";
15
+ export class MockAdoServer {
16
+ server;
17
+ routes = [];
18
+ baseUrl = "";
19
+ /** When true, every request returns 401 (simulates a dead session). */
20
+ failAuth = false;
21
+ /** Count of requests that arrived missing the mandatory FedAuthRedirect header. */
22
+ missingFedAuthHeaderCount = 0;
23
+ /** Register a handler. `host` is the real ADO host (e.g. "dev.azure.com"); omit for any. */
24
+ on(method, host, match, handler) {
25
+ const matcher = typeof match === "string"
26
+ ? (p) => p === match
27
+ : match instanceof RegExp
28
+ ? (p) => match.test(p)
29
+ : match;
30
+ this.routes.push({ method: method.toUpperCase(), host, match: matcher, handler });
31
+ return this;
32
+ }
33
+ /** Convenience: static JSON fixture for an exact method+host+path. */
34
+ fixture(method, host, pathname, response) {
35
+ return this.on(method, host, pathname, () => response);
36
+ }
37
+ async start() {
38
+ this.server = http.createServer((req, res) => this.handle(req, res));
39
+ await new Promise((resolve) => this.server.listen(0, "127.0.0.1", resolve));
40
+ const addr = this.server.address();
41
+ this.baseUrl = `http://127.0.0.1:${addr.port}`;
42
+ return this.baseUrl;
43
+ }
44
+ url() {
45
+ return this.baseUrl;
46
+ }
47
+ async stop() {
48
+ if (!this.server)
49
+ return;
50
+ await new Promise((resolve) => this.server.close(() => resolve()));
51
+ this.server = undefined;
52
+ }
53
+ async handle(req, res) {
54
+ const chunks = [];
55
+ for await (const c of req)
56
+ chunks.push(c);
57
+ const body = Buffer.concat(chunks).toString("utf8");
58
+ const realHost = req.headers["x-ado-real-host"] || "dev.azure.com";
59
+ const u = new URL(req.url ?? "/", `http://${realHost}`);
60
+ const mreq = {
61
+ method: (req.method ?? "GET").toUpperCase(),
62
+ host: realHost,
63
+ pathname: u.pathname,
64
+ query: u.searchParams,
65
+ body,
66
+ headers: req.headers,
67
+ };
68
+ // Enforce the mandatory anti-redirect header.
69
+ const fed = req.headers["x-tfs-fedauthredirect"];
70
+ if (fed !== "Suppress") {
71
+ this.missingFedAuthHeaderCount++;
72
+ return send(res, 400, { message: "missing or wrong X-TFS-FedAuthRedirect header" });
73
+ }
74
+ if (this.failAuth) {
75
+ return send(res, 401, { message: "Azure DevOps session expired (mock failAuth)" }, { "x-vss-mock": "failAuth" });
76
+ }
77
+ const route = this.routes.find((r) => r.method === mreq.method && (r.host === undefined || r.host === realHost) && r.match(mreq.pathname, mreq));
78
+ if (!route) {
79
+ return send(res, 404, { message: `no mock route for ${mreq.method} ${realHost}${mreq.pathname}` });
80
+ }
81
+ let out;
82
+ try {
83
+ out = await route.handler(mreq);
84
+ }
85
+ catch (e) {
86
+ return send(res, 500, { message: String(e) });
87
+ }
88
+ const status = out.status ?? 200;
89
+ const headers = { "x-vss-mock": "1", activityid: "mock-activity-0000", ...(out.headers ?? {}) };
90
+ if (out.buffer) {
91
+ res.writeHead(status, { "content-type": headers["content-type"] ?? "application/octet-stream", "content-length": String(out.buffer.length), ...stripContentHeaders(headers) });
92
+ res.end(out.buffer);
93
+ return;
94
+ }
95
+ return send(res, status, out.json ?? {}, headers);
96
+ }
97
+ }
98
+ function stripContentHeaders(h) {
99
+ const o = { ...h };
100
+ delete o["content-type"];
101
+ delete o["content-length"];
102
+ return o;
103
+ }
104
+ function send(res, status, json, headers = {}) {
105
+ const body = Buffer.from(JSON.stringify(json));
106
+ res.writeHead(status, { "content-type": "application/json", "content-length": String(body.length), ...headers });
107
+ res.end(body);
108
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Runtime wiring: builds the live AdoClient (browser session + cache + versions)
3
+ * and runs the interactive authenticate flow. Shared by the MCP server and the CLI.
4
+ */
5
+ import { loadConfig, requireConnection } from "./config.js";
6
+ import { BrowserSession } from "./browser/session.js";
7
+ import { SqliteCache } from "./cache/sqlite-cache.js";
8
+ import { AdoClient } from "./ado/client.js";
9
+ import { VersionRegistry } from "./ado/versions.js";
10
+ import { AuthRequiredError } from "./errors.js";
11
+ import { log } from "./logger.js";
12
+ /**
13
+ * Owns a SINGLE browser profile across the server's lifetime and arbitrates between
14
+ * the HEADLESS work session (data tools) and the HEADFUL interactive sign-in — so the
15
+ * profile lock is never held by two Chrome instances at once. This is what lets the
16
+ * `authenticate` MCP tool open the login window from inside the running server.
17
+ */
18
+ export class AdoRuntime {
19
+ cfg;
20
+ versions;
21
+ cache = null;
22
+ session = null;
23
+ client = null;
24
+ // NOTE: org is validated lazily (getClient/authenticate), NOT in the constructor —
25
+ // so `initialize`/`tools/list` work over stdio even before any org is configured.
26
+ constructor(cfg = loadConfig()) {
27
+ this.cfg = cfg;
28
+ this.versions = new VersionRegistry(cfg.apiVersionOverride);
29
+ }
30
+ newSession() {
31
+ requireConnection(this.cfg); // throws CONFIG_ERROR if ADO_ORG is missing
32
+ return new BrowserSession({ userDataDir: this.cfg.userDataDir, channel: this.cfg.browserChannel, org: this.cfg.org, versions: this.versions });
33
+ }
34
+ getCache() {
35
+ if (!this.cache)
36
+ this.cache = new SqliteCache({ dbPath: this.cfg.cacheDbPath, defaultTtlSeconds: this.cfg.cacheTtlSeconds, ttlOverrides: this.cfg.cacheTtlOverrides });
37
+ return this.cache;
38
+ }
39
+ /** Lazily (re)build the HEADLESS work client, reusing the persisted session cookies. */
40
+ async getClient() {
41
+ if (this.client && this.session)
42
+ return this.client;
43
+ this.session = this.newSession();
44
+ await this.session.ensureLaunched(true);
45
+ this.client = new AdoClient({ transport: this.session.transport, hosts: this.session.hosts, versions: this.versions, project: this.cfg.project, cache: this.getCache() });
46
+ return this.client;
47
+ }
48
+ async disposeSession() {
49
+ if (this.session)
50
+ await this.session.close();
51
+ this.session = null;
52
+ this.client = null;
53
+ }
54
+ /**
55
+ * Interactive sign-in usable as an MCP tool. If the session is already valid it
56
+ * returns immediately (no window). Otherwise it releases the headless session,
57
+ * opens a VISIBLE window, polls until sign-in (bounded), persists, and closes the
58
+ * window so the next data call relaunches headless and reuses the cookies.
59
+ */
60
+ async authenticate(timeoutMs) {
61
+ try {
62
+ await this.getClient();
63
+ if (await this.session.validate()) {
64
+ return { authenticated: true, identity: null, message: "Already signed in — the persisted session is still valid." };
65
+ }
66
+ }
67
+ catch {
68
+ /* fall through to interactive sign-in */
69
+ }
70
+ await this.disposeSession(); // free the profile lock for the headful window
71
+ const auth = this.newSession();
72
+ try {
73
+ const id = await auth.authenticate(timeoutMs);
74
+ return { authenticated: true, identity: id.displayName, message: `Signed in as ${id.displayName}. Session persisted; tools are ready.` };
75
+ }
76
+ catch (e) {
77
+ if (e instanceof AuthRequiredError) {
78
+ return { authenticated: false, identity: null, message: "Timed out waiting for sign-in. A browser window was opened — complete the login, then call `authenticate` again." };
79
+ }
80
+ throw e;
81
+ }
82
+ finally {
83
+ await auth.close();
84
+ }
85
+ }
86
+ async close() {
87
+ await this.disposeSession();
88
+ this.cache?.close();
89
+ this.cache = null;
90
+ }
91
+ }
92
+ export async function buildLiveRuntime(cfg = loadConfig(), opts) {
93
+ requireConnection(cfg);
94
+ const versions = new VersionRegistry(cfg.apiVersionOverride);
95
+ const session = new BrowserSession({ userDataDir: cfg.userDataDir, channel: cfg.browserChannel, org: cfg.org, versions });
96
+ await session.ensureLaunched(opts?.headless ?? cfg.headless);
97
+ const cache = new SqliteCache({ dbPath: cfg.cacheDbPath, defaultTtlSeconds: cfg.cacheTtlSeconds, ttlOverrides: cfg.cacheTtlOverrides });
98
+ const client = new AdoClient({ transport: session.transport, hosts: session.hosts, versions, project: cfg.project, cache });
99
+ return { client, session, cache, versions, cfg };
100
+ }
101
+ /** Clear the persisted session (and cached data) — a local "sign out". No org needed. */
102
+ export async function runLogout(cfg = loadConfig()) {
103
+ const fs = await import("node:fs");
104
+ let cleared = 0;
105
+ for (const target of [cfg.userDataDir, cfg.cacheDbPath]) {
106
+ if (fs.existsSync(target)) {
107
+ fs.rmSync(target, { recursive: true, force: true });
108
+ cleared++;
109
+ }
110
+ }
111
+ log.info(cleared > 0 ? `Logged out — browser session and cache cleared (${cfg.userDataDir}).` : "Nothing to clear — no persisted session found.");
112
+ return 0;
113
+ }
114
+ /** Report the configured org/profile and whether the persisted session is signed in. */
115
+ export async function runStatus(cfg = loadConfig()) {
116
+ log.info(`Profile dir : ${cfg.userDataDir}`);
117
+ log.info(`Cache DB : ${cfg.cacheDbPath}`);
118
+ if (!cfg.org) {
119
+ log.info("Org : (not set — pass --org or set ADO_ORG)");
120
+ log.info("Signed in : unknown (no org to check against)");
121
+ return 0;
122
+ }
123
+ log.info(`Org : ${cfg.org}${cfg.project ? ` Project: ${cfg.project}` : ""}`);
124
+ const session = new BrowserSession({ userDataDir: cfg.userDataDir, channel: cfg.browserChannel, org: cfg.org, versions: new VersionRegistry(cfg.apiVersionOverride) });
125
+ try {
126
+ const id = await session.whoami();
127
+ log.info(id ? `Signed in : yes — ${id.displayName}` : "Signed in : no — run `authenticate` to sign in");
128
+ return id ? 0 : 1;
129
+ }
130
+ finally {
131
+ await session.close();
132
+ }
133
+ }
134
+ /** Interactive (re)authentication: opens a VISIBLE browser, waits for sign-in, persists session. */
135
+ export async function runAuthenticate(cfg = loadConfig()) {
136
+ requireConnection(cfg);
137
+ const session = new BrowserSession({ userDataDir: cfg.userDataDir, channel: cfg.browserChannel, org: cfg.org, versions: new VersionRegistry(cfg.apiVersionOverride) });
138
+ const timeoutMs = (Number(process.env.ADO_AUTH_TIMEOUT_SECONDS) || 600) * 1000;
139
+ try {
140
+ const id = await session.authenticate(timeoutMs);
141
+ log.info(`Authentication OK — session persisted at ${cfg.userDataDir}. Identity: ${id.displayName}`);
142
+ // Prove the persisted session is reusable headless.
143
+ await session.close();
144
+ const ok = await session.validate();
145
+ log.info(ok ? "Headless re-validation OK (session reused without re-login)." : "WARNING: headless re-validation failed.");
146
+ return ok ? 0 : 1;
147
+ }
148
+ catch (e) {
149
+ log.error(`Authentication failed: ${String(e)}`);
150
+ return 1;
151
+ }
152
+ finally {
153
+ await session.close();
154
+ }
155
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Deterministic scrubbing of personal identifiers before anything is written to
3
+ * disk (fixtures, live-acceptance-report.json). Same input -> same pseudonym, so
4
+ * checksums stay stable across runs while emails/names/UPNs never leak.
5
+ */
6
+ import * as crypto from "node:crypto";
7
+ const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
8
+ /** Keys whose string values are treated as personal and pseudonymized wholesale. */
9
+ const PERSONAL_KEYS = new Set(["displayname", "uniquename", "mail", "principalname", "emailaddress", "directoryalias", "authenticateduser"]);
10
+ function pseudo(prefix, value) {
11
+ const h = crypto.createHash("sha256").update(value).digest("hex").slice(0, 8);
12
+ return `${prefix}-${h}`;
13
+ }
14
+ function scrubString(s) {
15
+ return s.replace(EMAIL_RE, (m) => `user-${crypto.createHash("sha256").update(m).digest("hex").slice(0, 8)}@example.invalid`);
16
+ }
17
+ export function scrub(value) {
18
+ return walk(value);
19
+ }
20
+ function walk(value) {
21
+ if (typeof value === "string")
22
+ return scrubString(value);
23
+ if (Array.isArray(value))
24
+ return value.map(walk);
25
+ if (value && typeof value === "object") {
26
+ const out = {};
27
+ for (const [k, v] of Object.entries(value)) {
28
+ if (typeof v === "string" && PERSONAL_KEYS.has(k.toLowerCase())) {
29
+ out[k] = pseudo("person", v);
30
+ }
31
+ else {
32
+ out[k] = walk(v);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ return value;
38
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * MCP server wiring. Registers exactly the 9 data tools (tools/list contract,
3
+ * mission §7). `authenticate` is a subcommand of the same binary (see index.ts),
4
+ * not a 10th tool — keeping tools/list at exactly 9 while staying one package.
5
+ *
6
+ * Every tool result is a well-formed content block. Failures map to a structured
7
+ * AdoError JSON with isError:true — never a stack trace, never a partial success.
8
+ */
9
+ import { z } from "zod";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { TOOL_DEFS } from "./tools/defs.js";
12
+ import { toAdoError } from "./tools/errors.js";
13
+ import { log } from "./logger.js";
14
+ const AUTH_TOOL_DESCRIPTION = "Sign in to Azure DevOps by opening a VISIBLE browser window for interactive login (MFA included). Run this once (or whenever a tool returns AUTH_REQUIRED). The session is persisted on an isolated profile and reused headless afterward — no PAT or token is ever stored.";
15
+ export function createMcpServer(deps) {
16
+ const server = new McpServer({ name: deps.name ?? "mcp-ado-browser", version: deps.version ?? "0.0.0" });
17
+ for (const t of TOOL_DEFS) {
18
+ server.registerTool(t.name, { description: t.description, inputSchema: t.inputShape }, async (args) => {
19
+ try {
20
+ const client = await deps.getClient();
21
+ const out = await t.run(client, args);
22
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
23
+ }
24
+ catch (e) {
25
+ const err = toAdoError(e);
26
+ log.warn(`tool ${t.name} failed: ${err.code} ${err.message}`);
27
+ return { isError: true, content: [{ type: "text", text: JSON.stringify(err.toJSON()) }] };
28
+ }
29
+ });
30
+ }
31
+ // The `authenticate` tool — folds the interactive sign-in into the MCP itself so
32
+ // setup is a single client-config step (no separate terminal command).
33
+ server.registerTool("authenticate", {
34
+ description: AUTH_TOOL_DESCRIPTION,
35
+ inputSchema: { timeoutSeconds: z.number().int().positive().max(600).optional().describe("How long to wait for sign-in (default 240s).") },
36
+ }, async (args) => {
37
+ if (!deps.authenticate) {
38
+ return { isError: true, content: [{ type: "text", text: JSON.stringify({ code: "CONFIG_ERROR", message: "Interactive authenticate is unavailable here. Run: npx mcp-ado-browser authenticate --org <org>" }) }] };
39
+ }
40
+ try {
41
+ const result = await deps.authenticate(args.timeoutSeconds ?? 240);
42
+ return { isError: !result.authenticated, content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
43
+ }
44
+ catch (e) {
45
+ const err = toAdoError(e);
46
+ log.warn(`authenticate failed: ${err.code} ${err.message}`);
47
+ return { isError: true, content: [{ type: "text", text: JSON.stringify(err.toJSON()) }] };
48
+ }
49
+ });
50
+ return server;
51
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The 9 read tools (mission §5). Each declares a zod INPUT shape (for tools/list
3
+ * + input validation) and a zod OUTPUT schema (drift detection). Handlers map
4
+ * arguments onto AdoClient calls and validate the result before returning.
5
+ *
6
+ * The same defs power both the live MCP server and the offline verify harness,
7
+ * so what the harness asserts is exactly what ships.
8
+ */
9
+ import { z } from "zod";
10
+ import { CommentDetailsSchema, DownloadedArtifactSchema, FeedsBrowseSchema, ProjectsListSchema, PullRequestCommentsSchema, PullRequestSchema, PullRequestSearchResultSchema, RepositoriesListSchema, WorkItemCommentsSchema, WorkItemSchema, WorkItemSearchResultSchema, validateOutput, } from "../ado/schemas.js";
11
+ function def(d) {
12
+ return d;
13
+ }
14
+ export const TOOL_DEFS = [
15
+ def({
16
+ name: "list_projects",
17
+ description: "List ALL Azure DevOps projects the user can access in the organization (GET _apis/projects). Use this to discover projects to browse.",
18
+ inputShape: {},
19
+ outputSchema: ProjectsListSchema,
20
+ run: (c) => c.listProjects().then((r) => validateOutput(ProjectsListSchema, r, "list_projects")),
21
+ }),
22
+ def({
23
+ name: "list_repositories",
24
+ description: "List ALL Git repositories the user can access across the organization (GET _apis/git/repositories), or within one project. Returns id, name and owning project for each repo.",
25
+ inputShape: {
26
+ project: z.string().optional().describe("Restrict to a single project (optional; omit for org-wide)."),
27
+ },
28
+ outputSchema: RepositoriesListSchema,
29
+ run: (c, a) => c.listRepositories(a).then((r) => validateOutput(RepositoriesListSchema, r, "list_repositories")),
30
+ }),
31
+ def({
32
+ name: "search_work_items",
33
+ description: "Search/browse work items ORG-WIDE (cross-project) by default. Backend is WIQL (POST _apis/wit/wiql); pass `text` for full-text Search (almsearch), or `project` to scope to one project. Returns id + summary fields.",
34
+ inputShape: {
35
+ wiql: z.string().optional().describe("Raw WIQL query. If omitted, a bounded recent-items query (@Me) is used."),
36
+ text: z.string().optional().describe("Full-text search string (uses almsearch; falls back to WIQL)."),
37
+ project: z.string().optional().describe("Restrict to a single project (optional; omit for org-wide)."),
38
+ top: z.number().int().positive().max(200).optional().describe("Max results (default 50)."),
39
+ },
40
+ outputSchema: WorkItemSearchResultSchema,
41
+ run: (c, a) => c.searchWorkItems(a).then((r) => validateOutput(WorkItemSearchResultSchema, r, "search_work_items")),
42
+ }),
43
+ def({
44
+ name: "get_work_item",
45
+ description: "Get a single work item with $expand=all, including `relations` (hierarchy, Related, and ArtifactLink PR references resolved).",
46
+ inputShape: {
47
+ id: z.number().int().positive().describe("Work item id."),
48
+ bypassCache: z.boolean().optional().describe("Force a fresh fetch, ignoring the cache."),
49
+ },
50
+ outputSchema: WorkItemSchema,
51
+ run: (c, a) => c.getWorkItem(a.id, { bypassCache: a.bypassCache }).then((r) => validateOutput(WorkItemSchema, r, "get_work_item")),
52
+ }),
53
+ def({
54
+ name: "get_work_item_comments",
55
+ description: "Get the full discussion for a work item (GET _apis/wit/workItems/{id}/comments — a SEPARATE endpoint, not part of $expand).",
56
+ inputShape: { id: z.number().int().positive().describe("Work item id.") },
57
+ outputSchema: WorkItemCommentsSchema,
58
+ run: (c, a) => c.getWorkItemComments(a.id).then((r) => validateOutput(WorkItemCommentsSchema, r, "get_work_item_comments")),
59
+ }),
60
+ def({
61
+ name: "get_comment_details",
62
+ description: "Resolve a work item (and optionally a specific comment) AND download all related attachments (work-item AttachedFile relations + attachments referenced in the comment body). Returns metadata + downloaded content stats (size, sha256).",
63
+ inputShape: {
64
+ workItemId: z.number().int().positive().describe("Work item id."),
65
+ commentId: z.number().int().positive().optional().describe("Specific comment id to resolve (optional)."),
66
+ saveDir: z.string().optional().describe("Directory to write downloaded attachment files to (optional)."),
67
+ },
68
+ outputSchema: CommentDetailsSchema,
69
+ run: (c, a) => c.getCommentDetails(a).then((r) => validateOutput(CommentDetailsSchema, r, "get_comment_details")),
70
+ }),
71
+ def({
72
+ name: "search_pull_requests",
73
+ description: "Search pull requests ORG-WIDE by default, or within a repo (repoId) or a project. Filters: status, creatorId, targetRef. (GET _apis/git/pullrequests)",
74
+ inputShape: {
75
+ repoId: z.string().optional().describe("Repository id (GUID) or name (omit for org-wide / project-wide search)."),
76
+ project: z.string().optional().describe("Restrict to a single project (optional)."),
77
+ status: z.string().optional().describe("active | completed | abandoned | all."),
78
+ creatorId: z.string().optional().describe("Creator identity id."),
79
+ targetRef: z.string().optional().describe("Target branch ref name, e.g. refs/heads/main."),
80
+ top: z.number().int().positive().max(200).optional(),
81
+ },
82
+ outputSchema: PullRequestSearchResultSchema,
83
+ run: (c, a) => c.searchPullRequests(a).then((r) => validateOutput(PullRequestSearchResultSchema, r, "search_pull_requests")),
84
+ }),
85
+ def({
86
+ name: "get_pull_request",
87
+ description: "Get a pull request with metadata, branches, reviewers and linked work items. Resolved org-wide — repoId may be a GUID or a repository name.",
88
+ inputShape: {
89
+ repoId: z.string().describe("Repository id (GUID) or name."),
90
+ prId: z.number().int().positive().describe("Pull request id."),
91
+ },
92
+ outputSchema: PullRequestSchema,
93
+ run: (c, a) => c.getPullRequest(a).then((r) => validateOutput(PullRequestSchema, r, "get_pull_request")),
94
+ }),
95
+ def({
96
+ name: "get_pull_request_comments",
97
+ description: "Get PR threads (system vs human). Resolved org-wide — repoId may be a GUID or a repository name.",
98
+ inputShape: {
99
+ repoId: z.string().describe("Repository id (GUID) or name."),
100
+ prId: z.number().int().positive().describe("Pull request id."),
101
+ },
102
+ outputSchema: PullRequestCommentsSchema,
103
+ run: (c, a) => c.getPullRequestComments(a).then((r) => validateOutput(PullRequestCommentsSchema, r, "get_pull_request_comments")),
104
+ }),
105
+ def({
106
+ name: "search_feeds",
107
+ description: "Browse Azure Artifacts feeds (GET feeds.dev.azure.com/_apis/packaging/feeds). Pass feedId to also list packages + versions.",
108
+ inputShape: { feedId: z.string().optional().describe("Feed id to browse for packages (optional).") },
109
+ outputSchema: FeedsBrowseSchema,
110
+ run: (c, a) => c.searchFeeds(a).then((r) => validateOutput(FeedsBrowseSchema, r, "search_feeds")),
111
+ }),
112
+ def({
113
+ name: "download_artifact",
114
+ description: "Download a package artifact (.nupkg / .tgz) from a feed via the browser session (pkgs.dev.azure.com). Validates archive integrity (size, sha256, valid zip/tgz) for re-hosting.",
115
+ inputShape: {
116
+ feedId: z.string().describe("Feed id (or name)."),
117
+ packageName: z.string().describe("Package name."),
118
+ version: z.string().describe("Exact version to download."),
119
+ protocol: z.enum(["nuget", "npm"]).describe("Package protocol."),
120
+ saveDir: z.string().describe("Directory to write the downloaded artifact to."),
121
+ },
122
+ outputSchema: DownloadedArtifactSchema,
123
+ run: (c, a) => c.downloadArtifact(a).then((r) => validateOutput(DownloadedArtifactSchema, r, "download_artifact")),
124
+ }),
125
+ ];
126
+ export function toolByName(name) {
127
+ return TOOL_DEFS.find((t) => t.name === name);
128
+ }