Files
compiler-explorer/lib/mcp/utils.ts
Matt Godbolt 312846230a Add built-in MCP endpoint for LLM tool integration (#8644)
Expose Compiler Explorer's compile, list, shortlink and asm-docs APIs
via a Model Context Protocol (MCP) endpoint mounted at `/mcp`. This lets
MCP-aware clients (Claude, etc.) drive CE directly as a tool.

## Tools exposed at `/mcp`

- **`compile`** — compile source and return assembly / stdout / stderr,
with optional execution.
- `compiler` is **optional**; falls back to the language's
`defaultCompiler` from `list_languages` ("compile this hello world in
C++" is one call).
- `libraries[].version` accepts **either** the version id (`"188"`) or
the human form (`"1.88.0"`) — both work.
- When `execute: true` and the build fails,
`buildResult.stdout`/`stderr` carry the real compiler diagnostics with
**ANSI codes stripped** so an LLM caller sees clean text.
- Caps: `maxAsmLines` / `maxStdoutLines` / `maxStderrLines` with
truncation flags + total counts.
- **`list_compilers`** — with `language`, `instructionSet` (closed enum
from `InstructionSetsList`), `match` (case-insensitive AND-of-tokens;
numeric/dotted-version tokens treated as version-prefix), `lean: true`,
`maxResults`, `latestPerMajor: true`, and `includeExperimental: true`.
- Hard cap of 200 entries on lean responses with a refinement hint —
prevents the unfiltered call from overflowing.
- Each entry exposes `releaseTrack` (`stable | nightly | prerelease |
experimental`) and `supportsExecute` / `supportsBinary`.
- **`list_libraries`** — with `match`, `lean`, `maxResults` (same
lean-cap behaviour).
- **`list_languages`** — minimal listing including `defaultCompiler` and
`compilerCount` per language.
- **`generate_short_url`** — returns `{url}`. Library versions are
normalised before saving.
- **`get_shortlink_info`** — returns saved sessions in the **same shape
`compile` accepts** (`{compiler, options, libraries:[{id, version}]}`)
for direct round-tripping. Multi-pane shortlinks (executors,
conformance, CMake trees) are flattened to the basic compile inputs.
- **`lookup_asm_instruction`** — `instruction_set` is a closed enum
derived from the registered providers (no hand-listed enum values; one
source of truth in `lib/asm-docs/`).

## Implementation

- New `lib/mcp/` module wiring `@modelcontextprotocol/sdk` into the
existing Express router via `StreamableHTTPServerTransport` (stateless
mode — one server per request).
- `lib/mcp/utils.ts`: tokenised `match` with version-prefix matching for
numeric/dotted-version tokens (so `"gcc 14.1"` matches `"gcc 14.1"` and
`"gcc 14.1.0"` but NOT `"gcc 14.10"` or `"gcc 14.0.1"`); `applyCap` with
both per-call lean degradation and an absolute hard cap; `truncateLines`
strips ANSI escapes via the existing `filterEscapeSequences` helper from
`lib/utils.ts`.
- `lib/mcp/library-utils.ts`: `normaliseLibraryVersion` and
`normaliseRequestLibraries` — single source of truth for "accept id or
human version" semantics, used by both `compile` and
`generate_short_url`.
- Schema descriptions are tight (LLM context cost matters) and derive
closed-set enums programmatically from `InstructionSetsList`,
`RELEASE_TRACKS`, and a new `availableAsmDocsKeys` export — no
hand-listed values that can rot.
- Refactor `StorageBase` static helpers (`encodeBuffer`, `isCleanText`,
`getSafeHash`) to module-level functions with type-checked input so MCP
tools can build shortlink hashes without instantiating a storage
backend.
- Expose `ApiHandler.compileHandler` and split out
`getAvailableLanguages()` so MCP can reuse the same code paths the REST
API uses; new `ApiHandler.getDefaultCompilerFor()` for the
compile-default-compiler resolution.
- Browser-friendly CORS on `/mcp`: OPTIONS preflight advertises
`Access-Control-Allow-Methods: POST, OPTIONS` (the shared `cors`
middleware doesn't set Methods); 405 responses on other verbs use the
same Allow header.
- `docs/API.md`: clarify that `/api/shortener` requires a JSON object
body (the prior docs implied but didn't state it).

## Tester feedback addressed

A Claude tester drove the staging deployment through several rounds;
full thread in PR comments. Round-by-round refinements:

- Compile diagnostics surfaced on execute-mode build failures (the
original "silent `Build failed` with empty stderr" bug).
- `execute: true` schema description rewritten to reflect the actual
behaviour.
- Library `version` accepts both forms; clean errors when neither
matches with a sample of available versions.
- `latestPerMajor` rebuilt on top of the `releaseTrack` field added in
#8685, with `includeExperimental` opt-in for c++ proposal forks.
- Lean mode (`lean: true`) for catalog browsing, plus a hard 200-item
cap so even unfiltered calls don't overflow the host.
- Tokenised `match` with version-prefix semantics for
numeric/dotted-version tokens. **Behaviour change** vs the old
`/api/compilers?fields=...` text matching: bare numeric tokens are now
treated as version segments — `"2024"` no longer substring-matches
inside `"v2024beta"`, and `"14.1"` no longer wrongly matches `"14.0.1"`.
Strict improvements but worth a release-note line for callers depending
on the prior loose behaviour.
- ANSI escape code stripping from compile output.
- `instructionSet` as a structured filter (instead of relying on `match`
strings).
- `supportsExecute` / `supportsBinary` on `list_compilers` so an agent
knows whether `execute: true` will work without trying.
- `compilerCount` per language so an agent can tell well-stocked vs
niche languages at a glance.
- Compiler-not-found / library-not-found errors point at the right
`list_*` tool.

## Depends on

#8685 (releaseTrack metadata on `CompilerInfo`) — merged.

## Test plan

- [x] `npm run test -- --run mcp release-track` — all pass (78 + 22)
- [x] `npm run test-min` — full minus expensive, all green
- [x] `make pre-commit` — exits 0
- [x] Multi-round driving on staging via the live MCP endpoint,
including: default-compiler hello-world (no compiler arg), human-form
library version (`"1.88.0"`), broken-compile-with-execute (verifies
buildResult), compile+run with stdin, compile+library Boost 1.90,
parallel `-O0` vs `-O3` diff, `list_compilers latestPerMajor` for
c++/rust/go/csharp, `list_libraries match boost/fmt/json`,
`lookup_asm_instruction MOV amd64`, full `generate_short_url` →
`get_shortlink_info` → re-`compile` round-trip with library
normalisation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Matt Godbolt <mattgodbolt@hudson-trading.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: mattgodbolt-molty <mattgodbolt-molty@users.noreply.github.com>
2026-05-08 10:50:09 -04:00

159 lines
6.8 KiB
TypeScript

// Copyright (C) 2026 Hudson River Trading LLC <opensource@hudson-trading.com>
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// * Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
import type {ResultLine} from '../../types/resultline/resultline.interfaces.js';
import {filterEscapeSequences} from '../utils.js';
// Absolute upper bound on items returned in lean mode. Without this, calling
// list_compilers without filters can return 1000+ entries (~88KB just for c++,
// 23k+ lines globally) and overwhelm the LLM caller's response limit. With the
// cap an unfiltered call still returns SOMETHING usable, plus a hint pushing
// the agent to refine via match / language / instructionSet / latestPerMajor.
const LEAN_HARD_CAP = 200;
// Replace anything that isn't alphanumeric, '+' (kept for "c++"), or '.' (kept so
// dotted version tokens like "14.1" stay together) with whitespace, then collapse
// runs of whitespace. Lets "x86-64 gcc trunk" match "x86-64 gcc (trunk)".
function normalise(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9+.]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Numeric (or dotted-numeric) tokens get a stricter "version prefix" match so
// "14.1" finds "14.1" and "14.1.x" but NOT "14.10" or "14.0.1". Alphanumeric
// tokens still match as substrings so partial-id queries like "g14" find "g142".
const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/;
export function applyMatch<T>(items: T[], pattern: string | undefined, extract: (item: T) => string[]): T[] {
if (!pattern) return items;
const tokens = normalise(pattern).split(' ').filter(Boolean);
if (tokens.length === 0) return items;
return items.filter(item => {
const normalised = normalise(extract(item).join(' '));
const sentinelled = ` ${normalised} `;
return tokens.every(token => {
if (!NUMERIC_TOKEN.test(token)) return normalised.includes(token);
// Numeric/dotted-numeric: token must be followed by either a separator
// (whitespace) or another version segment (`.` then more digits). This
// makes "14.1" match "14.1" and "14.1.0" but not "14.10" or "14.0.1".
return sentinelled.includes(` ${token} `) || sentinelled.includes(` ${token}.`);
});
});
}
export type LeanShape = {id: string; name: string};
export type CappedResult<F, L = LeanShape> = {
items: F[] | L[];
total: number;
leanMode?: true;
hint?: string;
};
const defaultLeanMap = <T extends {id: string; name?: string}>(item: T): LeanShape => ({
id: item.id,
name: item.name ?? '',
});
/**
* Caller-requested lean mode (`forceLean: true`) returns items mapped through
* `leanMap` (defaults to `{id, name}`) — useful for browsing the catalog index
* before drilling down by exact id, without first overflowing the response.
*
* Otherwise, below `maxResults` returns items mapped through `fullMap` (the
* full-detail shape). At or above the cap, degrades to lean mode automatically
* with a `leanMode: true` marker plus an LLM-facing hint suggesting refinement.
*
* In every lean path the response is hard-capped at `LEAN_HARD_CAP` items so a
* very broad query still returns a useful (truncated) list rather than a
* megabyte of JSON the host MCP client will reject. The hint always carries the
* `total` so the caller knows refinement is needed.
*/
export function applyCap<T extends {id: string; name?: string}, F, L = LeanShape>(
items: T[],
maxResults: number,
fullMap: (item: T) => F,
entityName: string,
leanMap?: (item: T) => L,
forceLean = false,
): CappedResult<F, L> {
const lean = leanMap ?? (defaultLeanMap as unknown as (item: T) => L);
const total = items.length;
const exceedsFullCap = total > maxResults;
const useLean = forceLean || exceedsFullCap;
if (!useLean) {
return {items: items.map(fullMap), total};
}
const truncated = total > LEAN_HARD_CAP;
const kept = truncated ? items.slice(0, LEAN_HARD_CAP) : items;
let hint: string | undefined;
if (exceedsFullCap && truncated) {
hint =
`${total} ${entityName} matched and the response is too large to return in full; ` +
`showing the first ${LEAN_HARD_CAP} (id and name only). Refine with \`match\`, \`language\`, ` +
'`instructionSet`, or `latestPerMajor` to narrow the result.';
} else if (exceedsFullCap) {
hint =
`${total} ${entityName} exceeded the full-detail cap of ${maxResults}; showing id and name only. ` +
'Refine your filter (e.g. add a version or architecture), use `lean: true` explicitly to confirm ' +
'this shape, or query again with the exact id for full details.';
} else if (truncated) {
hint =
`Lean response capped at ${LEAN_HARD_CAP} of ${total} ${entityName}; refine your filter ` +
'(`match`, `language`, `instructionSet`) to see the rest.';
}
return {
items: kept.map(lean),
total,
leanMode: true,
...(hint && {hint}),
};
}
export type TruncatedLines = {
text: string;
truncated: boolean;
totalLines: number;
};
export function truncateLines(lines: ResultLine[] | null | undefined, maxLines: number): TruncatedLines {
const all = lines || [];
const truncated = all.length > maxLines;
const kept = truncated ? all.slice(0, maxLines) : all;
// Strip ANSI escape sequences (gcc/clang colour their diagnostics; MCP
// consumers don't have terminals so the codes are pure noise).
return {
text: kept.map(line => filterEscapeSequences(line.text)).join('\n'),
truncated,
totalLines: all.length,
};
}