Skip to content

Commit 5511321

Browse files
committed
Derive stdio MCP display name from the package, not the runner
The stdio add form fell back to the bare command (npx, uvx, ...) for both the display name and namespace, so every npm-launched server landed as "npx". Derive a name from the package/module being run instead: skip the runner and its flags/subcommands, drop the npm scope, version, path, and the usual MCP affixes (mcp-server-, server-, -mcp), then title-case. So "npx -y @modelcontextprotocol/server-github" becomes "Github MCP" and "uvx mcp-server-time" becomes "Time MCP", falling back to the raw command when nothing meaningful can be extracted.
1 parent 901e592 commit 5511321

3 files changed

Lines changed: 151 additions & 12 deletions

File tree

packages/plugins/mcp/src/react/AddMcpSource.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Input } from "@executor-js/react/components/input";
2020
import { Spinner } from "@executor-js/react/components/spinner";
2121
import { TagInput } from "@executor-js/react/components/tag-input";
2222
import {
23+
integrationDisplayNameFromStdio,
2324
integrationDisplayNameFromUrl,
2425
slugifyNamespace,
2526
IntegrationIdentityFields,
@@ -57,6 +58,19 @@ function findPreset(id: string | undefined): McpPreset | undefined {
5758
return mcpPresets.find((p) => p.id === id);
5859
}
5960

61+
// Splits the raw args field into tokens, honoring double-quoted groups so an
62+
// argument with spaces stays intact.
63+
function parseStdioArgs(raw: string): string[] {
64+
if (!raw.trim()) return [];
65+
const args: string[] = [];
66+
const regex = /[^\s"]+|"([^"]*)"/g;
67+
let match;
68+
while ((match = regex.exec(raw)) !== null) {
69+
args.push(match[1] ?? match[0]);
70+
}
71+
return args;
72+
}
73+
6074
// ---------------------------------------------------------------------------
6175
// State machine (remote flow)
6276
// ---------------------------------------------------------------------------
@@ -174,7 +188,10 @@ export default function AddMcpSource(props: {
174188
);
175189
const [stdioEnvVars, setStdioEnvVars] = useState<string[]>([]);
176190
const stdioIdentity = useIntegrationIdentity({
177-
fallbackName: isStdioPreset ? preset.name : stdioCommand,
191+
fallbackName: isStdioPreset
192+
? preset.name
193+
: (integrationDisplayNameFromStdio(stdioCommand, parseStdioArgs(stdioArgs), "MCP") ??
194+
stdioCommand),
178195
});
179196
const [stdioAdding, setStdioAdding] = useState(false);
180197
const [stdioError, setStdioError] = useState<string | null>(null);
@@ -333,17 +350,6 @@ export default function AddMcpSource(props: {
333350

334351
// ---- Stdio actions ----
335352

336-
const parseStdioArgs = (raw: string): string[] => {
337-
if (!raw.trim()) return [];
338-
const args: string[] = [];
339-
const regex = /[^\s"]+|"([^"]*)"/g;
340-
let match;
341-
while ((match = regex.exec(raw)) !== null) {
342-
args.push(match[1] ?? match[0]);
343-
}
344-
return args;
345-
};
346-
347353
const handleAddStdio = useCallback(async () => {
348354
const cmd = stdioCommand.trim();
349355
if (!cmd) return;

packages/react/src/plugins/integration-identity.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { describe, expect, it } from "@effect/vitest";
22

33
import {
44
domainLabelFromUrl,
5+
humanizeStdioToken,
6+
integrationDisplayNameFromStdio,
57
integrationDisplayNameFromUrl,
68
pascalCaseDomainLabel,
9+
stdioPackageToken,
710
} from "./integration-identity";
811

912
describe("integration identity URL display names", () => {
@@ -22,3 +25,43 @@ describe("integration identity URL display names", () => {
2225
);
2326
});
2427
});
28+
29+
describe("integration identity stdio display names", () => {
30+
it("picks the package spec past the runner and its flags", () => {
31+
expect(stdioPackageToken("npx", ["-y", "@modelcontextprotocol/server-github"])).toBe(
32+
"@modelcontextprotocol/server-github",
33+
);
34+
expect(stdioPackageToken("pnpm", ["dlx", "mcp-server-time"])).toBe("mcp-server-time");
35+
expect(stdioPackageToken("uvx", ["mcp-server-time"])).toBe("mcp-server-time");
36+
});
37+
38+
it("uses the command itself only when it is not a generic runner", () => {
39+
expect(stdioPackageToken("npx", [])).toBeNull();
40+
expect(stdioPackageToken("my-mcp-server", [])).toBe("my-mcp-server");
41+
});
42+
43+
it("strips npm scope, MCP affixes, versions, and paths when humanizing", () => {
44+
expect(humanizeStdioToken("@modelcontextprotocol/server-github")).toBe("Github");
45+
expect(humanizeStdioToken("mcp-server-sequential-thinking")).toBe("Sequential Thinking");
46+
expect(humanizeStdioToken("@scope/notion-mcp@1.2.3")).toBe("Notion");
47+
expect(humanizeStdioToken("./build/index.js")).toBe("Index");
48+
});
49+
50+
it("derives a humanized name with the kind suffix, ignoring trailing path args", () => {
51+
expect(
52+
integrationDisplayNameFromStdio("npx", ["-y", "@modelcontextprotocol/server-github"], "MCP"),
53+
).toBe("Github MCP");
54+
expect(
55+
integrationDisplayNameFromStdio(
56+
"npx",
57+
["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/work"],
58+
"MCP",
59+
),
60+
).toBe("Filesystem MCP");
61+
expect(integrationDisplayNameFromStdio("uvx", ["mcp-server-time"], "MCP")).toBe("Time MCP");
62+
});
63+
64+
it("returns null for a bare runner so callers can fall back to the command", () => {
65+
expect(integrationDisplayNameFromStdio("npx", [], "MCP")).toBeNull();
66+
});
67+
});

packages/react/src/plugins/integration-identity.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,96 @@ export function integrationDisplayNameFromUrl(url: string, sourceKind: string):
4646
return displayLabel ? `${displayLabel} ${sourceKind}` : null;
4747
}
4848

49+
// Package runners whose own name is never the integration name: the meaningful
50+
// token is the package or module they execute, not the runner itself.
51+
const STDIO_RUNNERS = new Set([
52+
"npx",
53+
"bunx",
54+
"pnpm",
55+
"yarn",
56+
"npm",
57+
"uvx",
58+
"uv",
59+
"pipx",
60+
"bun",
61+
"deno",
62+
"node",
63+
"python",
64+
"python3",
65+
]);
66+
67+
// Subcommands that sit between a runner and its package: `pnpm dlx <pkg>`,
68+
// `npm exec <pkg>`, `uv run <pkg>`, `deno run <spec>`.
69+
const STDIO_RUNNER_SUBCOMMANDS = new Set(["dlx", "exec", "run", "x", "--"]);
70+
71+
/**
72+
* Picks the package/module spec from a stdio launch command: the first
73+
* positional argument that isn't a runner flag or subcommand, falling back to
74+
* the command itself when it isn't a generic runner (a server invoked
75+
* directly). Returns `null` when only a bare runner is present.
76+
*/
77+
export function stdioPackageToken(command: string, args: readonly string[]): string | null {
78+
for (const raw of args) {
79+
const arg = raw.trim();
80+
if (!arg || arg.startsWith("-")) continue; // skip flags like -y, --yes
81+
if (STDIO_RUNNER_SUBCOMMANDS.has(arg)) continue; // skip dlx / exec / run
82+
return arg;
83+
}
84+
const cmd = command.trim();
85+
if (!cmd || STDIO_RUNNERS.has(cmd.toLowerCase())) return null;
86+
return cmd;
87+
}
88+
89+
/**
90+
* Reduces a package/module spec to human words: drops an npm scope, a version
91+
* or tag suffix, a path and file extension, and the noise affixes MCP packages
92+
* conventionally carry (`mcp-server-`, `server-`, `-mcp`), then title-cases.
93+
* Returns `null` when nothing alphanumeric survives.
94+
*/
95+
export function humanizeStdioToken(token: string): string | null {
96+
let name = token.trim();
97+
if (name.startsWith("@")) {
98+
// Scoped npm package: @modelcontextprotocol/server-github → server-github.
99+
name = name.split("/").pop() ?? name;
100+
} else if (/[\\/]/.test(name)) {
101+
// A filesystem path (node ./build/index.js) → its base filename.
102+
name = name.split(/[\\/]/).pop() ?? name;
103+
name = name.replace(/\.[a-z0-9]+$/i, ""); // strip extension
104+
}
105+
name = name.replace(/@[^@/]*$/, "") || name; // drop a trailing @version / @tag
106+
name = name
107+
.replace(/^mcp[-_]server[-_]/i, "")
108+
.replace(/^mcp[-_]/i, "")
109+
.replace(/^server[-_]/i, "")
110+
.replace(/[-_]mcp[-_]server$/i, "")
111+
.replace(/[-_]mcp$/i, "")
112+
.replace(/[-_]server$/i, "");
113+
const words = name
114+
.split(/[^a-z0-9]+/i)
115+
.map((word) => word.trim())
116+
.filter(Boolean);
117+
if (words.length === 0) return null;
118+
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ");
119+
}
120+
121+
/**
122+
* Derives a display-name candidate from a stdio launch command by extracting
123+
* the package/module being run and humanizing it, e.g.
124+
* `npx -y @modelcontextprotocol/server-github` → `"Github MCP"`,
125+
* `uvx mcp-server-time` → `"Time MCP"`, `node ./build/index.js` → `"Index MCP"`.
126+
* Returns `null` when nothing meaningful can be extracted, so callers can fall
127+
* back to the raw command.
128+
*/
129+
export function integrationDisplayNameFromStdio(
130+
command: string,
131+
args: readonly string[],
132+
sourceKind: string,
133+
): string | null {
134+
const token = stdioPackageToken(command, args);
135+
const label = token ? humanizeStdioToken(token) : null;
136+
return label ? `${label} ${sourceKind}` : null;
137+
}
138+
49139
// ---------------------------------------------------------------------------
50140
// Hook — owns the name + namespace state with namespace auto-derivation
51141
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)