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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/sync-from-monorepo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ jobs:
# a fifty-file diff.
REMOVED="$(git status --porcelain -- mcp-server/src/tools skills \
| awk '$1 ~ /D/ { print $2 }' | tr '\n' ' ')"
# A deleted file is not the only way to withdraw a tool: a rename
# keeps every file and changes the name clients call, which is how
# `currents-get-affected-executions` went without a warning. The
# names are compared as registered in server.ts, before and after.
# The script comes from main's checkout; sync-readme-tools.mjs above
# already ran, so a pattern that no longer matches has failed there.
git show HEAD:mcp-server/src/server.ts > /tmp/server.before.ts
WITHDRAWN="$(node mcp-server/scripts/withdrawn-tools.mjs \
/tmp/server.before.ts mcp-server/src/server.ts | tr '\n' ' ')"
REMOVED="${REMOVED}${WITHDRAWN}"
REMOVED="${REMOVED% }"
echo "removed=$REMOVED" >> "$GITHUB_OUTPUT"
if [ -n "$REMOVED" ]; then
echo "::warning::This sync withdraws: $REMOVED"
Expand Down
38 changes: 8 additions & 30 deletions mcp-server/scripts/sync-readme-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { loadSkills } from "./load-skills.mjs";
import { registeredTools as parseTools } from "./tool-names.mjs";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const readmePath = join(root, "..", "README.md");
Expand Down Expand Up @@ -46,28 +47,7 @@ const origResolve = await (async () => {

const serverSrc = readFileSync(join(root, "src", "server.ts"), "utf-8");

// Quote-agnostic: the source is prettier-formatted with singleQuote, but the
// monorepo copy this file's subject is synced from has been both.
// Anchored on the tool name and its description, not on whatever function
// declares them. That call has been renamed three times — `server.registerTool`,
// then a local `registerTool` wrapper applying scope filtering, then
// `catalogTool` building a TOOL_CATALOG the server loops over — and each rename
// silently found zero tools until someone noticed.
//
// `currents-` is the stable part: `server.test.ts` asserts every registered name
// matches it, so a tool that stopped being found here would have to stop being a
// tool. The loop that registers them passes variables, so it cannot match and
// nothing is counted twice.
//
// The character class is the one `server.test.ts` allows, dots and slashes
// included, rather than the narrower `\w`. A name outside it would be skipped
// rather than reported: this only refuses to run when it finds *no* tools, so a
// partial match writes a README missing a tool and says nothing. That would be
// caught next by `host/readme.test.ts`, which fails when a registered tool is
// absent from the table — one step later, and for a reason that does not name
// the cause.
const toolRegex =
/(['"])(currents-[A-Za-z0-9_./-]+)\1\s*,\s*\{\s*description:\s*(['"])((?:\\.|(?!\3)[^\\])*)\3/g;
// What the pattern matches, and why, is documented in `tool-names.mjs`.

/**
* The lead sentence of a description, for a table cell. Both catalogs carry
Expand All @@ -94,10 +74,10 @@ function firstSentence(description) {
/**
* A string literal's source text as the string it declares.
*
* The regex above captures what is between the quotes, so every escape in it
* is still two characters. Decoding the quotes alone left `\\` as a pair, which
* the markdown escape below then doubled again — a description declaring
* `C:\Users` reached the README as two backslashes.
* The pattern in `tool-names.mjs` captures what is between the quotes, so
* every escape in it is still two characters. Decoding the quotes alone left
* `\\` as a pair, which the markdown escape below then doubled again — a
* description declaring `C:\Users` reached the README as two backslashes.
*
* One pass rather than chained replaces, so a decoded backslash is not read
* again as the start of the next escape.
Expand All @@ -111,10 +91,8 @@ function decodeStringLiteral(literal) {
);
}

let match;
while ((match = toolRegex.exec(serverSrc)) !== null) {
const name = match[2];
const description = decodeStringLiteral(match[4]);
for (const { name, rawDescription } of parseTools(serverSrc)) {
const description = decodeStringLiteral(rawDescription);
registeredTools.push({ name, shortDesc: firstSentence(description) });
}

Expand Down
98 changes: 98 additions & 0 deletions mcp-server/scripts/tool-names.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* The tool registrations in `server.ts`, read from its source rather than by
* importing it, so nothing the file carries is executed.
*
* Shared by `sync-readme-tools.mjs`, which builds the README table from them,
* and `withdrawn-tools.mjs`, which compares two copies of `server.ts` to find a
* tool a sync removes or renames. One pattern for both, so the check for a
* withdrawn tool cannot quietly find fewer tools than the README lists.
*/

// Quote-agnostic: the source is prettier-formatted with singleQuote, but the
// monorepo copy this file's subject is synced from has been both.
// Anchored on the tool name and its description, not on whatever function
// declares them. That call has been renamed three times — `server.registerTool`,
// then a local `registerTool` wrapper applying scope filtering, then
// `catalogTool` building a TOOL_CATALOG the server loops over — and each rename
// silently found zero tools until someone noticed.
//
// `currents-` is the stable part: `server.test.ts` asserts every registered name
// matches it, so a tool that stopped being found here would have to stop being a
// tool. The loop that registers them passes variables, so it cannot match and
// nothing is counted twice.
//
// The character class is the one `server.test.ts` allows, dots and slashes
// included, rather than the narrower `\w`. A name outside it would be skipped
// rather than reported: this only refuses to run when it finds *no* tools, so a
// partial match writes a README missing a tool and says nothing. That would be
// caught next by `host/readme.test.ts`, which fails when a registered tool is
// absent from the table — one step later, and for a reason that does not name
// the cause.
const TOOL_PATTERN =
/(['"])(currents-[A-Za-z0-9_./-]+)\1\s*,\s*\{\s*description:\s*(['"])((?:\\.|(?!\3)[^\\])*)\3/g;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
miguelangaranocurrents marked this conversation as resolved.

const LINE_END = /[\n\r\u2028\u2029]/g;

/**
* `src` with every comment blanked to spaces, line breaks kept.
*
* A registration left behind in a comment is not a tool, and counting it as
* one would hide exactly the withdrawal `withdrawn-tools.mjs` is looking for:
* the name disappears from the catalog but still matches here.
*
* Strings and template literals are stepped over, so a `//` inside one (a URL
* in a description) does not start a comment. Two things are not handled,
* because `server.ts` has neither: regex literals, and a backtick nested inside
* a template's `${}`.
*
* @param {string} src
* @returns {string}
*/
export function withoutComments(src) {
let out = "";
let i = 0;
while (i < src.length) {
const char = src[i];
const next = src[i + 1];
if (char === "/" && next === "/") {
// Every line terminator JavaScript recognises, not only LF: a comment
// running on past a CR would blank the registrations after it.
LINE_END.lastIndex = i;
const stop = LINE_END.exec(src)?.index ?? src.length;
out += " ".repeat(stop - i);
i = stop;
} else if (char === "/" && next === "*") {
const end = src.indexOf("*/", i + 2);
const stop = end === -1 ? src.length : end + 2;
out += src.slice(i, stop).replace(/[^\n\r\u2028\u2029]/g, " ");
i = stop;
} else if (char === "'" || char === '"' || char === "`") {
let j = i + 1;
while (j < src.length && src[j] !== char) {
j += src[j] === "\\" ? 2 : 1;
}
out += src.slice(i, j + 1);
i = j + 1;
} else {
out += char;
i += 1;
}
}
return out;
}

/**
* Every tool `serverSrc` registers, in source order, with its description as
* written between the quotes (escapes still in place). Registrations inside
* comments are not counted.
*
* @param {string} serverSrc
* @returns {{ name: string, rawDescription: string }[]}
*/
export function registeredTools(serverSrc) {
const code = withoutComments(serverSrc);
return [...code.matchAll(TOOL_PATTERN)].map((match) => ({
name: match[2],
rawDescription: match[4],
}));
}
43 changes: 43 additions & 0 deletions mcp-server/scripts/withdrawn-tools.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Prints the tools registered in one `server.ts` and absent from another, one
* per line, so a sync can tell whether it withdraws a tool.
*
* Deleted files are not enough to tell. The tools live in files under
* `src/tools`, but the name a client calls is the string `server.ts`
* registers, and a rename changes that string while leaving every file in
* place. `currents-get-affected-executions` became
* `currents-get-action-executions` that way, and the sync reported nothing
* withdrawn.
*
* Usage: node scripts/withdrawn-tools.mjs <before/server.ts> <after/server.ts>
*
* Exits 1 when either file registers no tools: a pattern that stopped
* matching would otherwise report every tool withdrawn, or none.
*/

import { readFileSync } from "node:fs";
import { registeredTools } from "./tool-names.mjs";

const [beforePath, afterPath] = process.argv.slice(2);
if (!beforePath || !afterPath) {
console.error("usage: withdrawn-tools.mjs <before/server.ts> <after/server.ts>");
process.exit(2);
}

/** @param {string} path */
function namesIn(path) {
const names = new Set(
registeredTools(readFileSync(path, "utf-8")).map((tool) => tool.name),
);
if (names.size === 0) {
console.error(`ERROR: no tools found in ${path}`);
process.exit(1);
}
return names;
}

const before = namesIn(beforePath);
const after = namesIn(afterPath);
for (const name of before) {
if (!after.has(name)) console.log(name);
}
Loading