Files
compiler-explorer/lib/storage/base.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

162 lines
6.3 KiB
TypeScript

// Copyright (c) 2018, Compiler Explorer Authors
// 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 * as express from 'express';
import {profanities} from 'profanities';
import {assert} from '../assert.js';
import {logger} from '../logger.js';
import {PropertyGetter} from '../properties.interfaces.js';
import {CompilerProps} from '../properties.js';
import * as utils from '../utils.js';
const FILE_HASH_VERSION = 'Compiler Explorer Config Hasher 2';
/* How long a string to check for possible unusable hashes (Profanities or confusing text)
Note that a Hash might end up being longer than this!
*/
const USABLE_HASH_CHECK_LENGTH = 9; // Quite generous
const MAX_TRIES = 4;
export type ExpandedShortLink = {
config: string;
specialMetadata?: any;
created?: Date;
};
export type StoredObject = {
prefix: string;
uniqueSubHash: string;
fullHash: string;
config: string;
};
export function encodeBuffer(buffer: Buffer): string {
return utils.base32Encode(buffer);
}
export function isCleanText(text: string) {
const lowercased = text.toLowerCase();
return !profanities.some(badWord => lowercased.includes(badWord));
}
function getRawConfigHash(config: any) {
return encodeBuffer(utils.getBinaryHash(JSON.stringify(config), FILE_HASH_VERSION));
}
export function getSafeHash(inputConfig: Record<string, any>) {
// Reject anything that isn't a non-null object up-front. Spread of a string
// would silently produce a per-character map ({0: 'h', 1: 'i', ...}) and
// hash that instead of the original; spread of null/undefined would yield
// {} and hash an empty object. All in-tree call sites pass plain objects
// (or class instances without toJSON, which behave identically under
// JSON.stringify), so this is purely a defence against future drift.
assert(
inputConfig !== null && typeof inputConfig === 'object' && !Array.isArray(inputConfig),
`getSafeHash: expected a non-null object, got ${inputConfig === null ? 'null' : typeof inputConfig}`,
);
// Shallow-clone so the nonce-rehashing loop doesn't mutate the caller's
// object. The nonce is added at the top level only, so a shallow copy is
// enough; the returned `config` string includes it via JSON.stringify.
let config: any = {...inputConfig};
let configHash = getRawConfigHash(config);
let tries = 1;
while (!isCleanText(configHash.substring(0, USABLE_HASH_CHECK_LENGTH))) {
// Shake up the hash a bit by adding, or incrementing a nonce value.
config.nonce = tries;
logger.info(`Unusable text found in full hash ${configHash} - Trying again (${tries})`);
if (tries <= MAX_TRIES) {
configHash = getRawConfigHash(config);
++tries;
} else {
logger.warn(`Gave up trying to find clean text for ${configHash}`);
break;
}
}
// And stringify it for the rest of the request
config = JSON.stringify(config);
return {config, configHash};
}
function configFor(req: express.Request) {
if (req.body.config) {
return req.body.config;
}
if (req.body.sessions) {
return req.body;
}
return null;
}
export abstract class StorageBase {
constructor(
public readonly httpRootDir: string,
protected readonly compilerProps: CompilerProps | PropertyGetter,
) {}
handler(req: express.Request, res: express.Response) {
// Get the desired config and check for profanities in its hash
const origConfig = configFor(req);
if (!origConfig) {
logger.error('No configuration found');
res.status(500);
res.send('Missing config parameter');
return;
}
const {config, configHash} = getSafeHash(origConfig);
this.findUniqueSubhash(configHash)
.then(result => {
logger.info(
`Unique subhash '${result.uniqueSubHash}' ` +
`(${result.alreadyPresent ? 'was already present' : 'newly-created'})`,
);
if (result.alreadyPresent) {
return result;
}
const storedObject: StoredObject = {
prefix: result.prefix,
uniqueSubHash: result.uniqueSubHash,
fullHash: configHash,
config: config,
};
return this.storeItem(storedObject, req);
})
.then(result => {
res.send({url: `${req.protocol}://${req.get('host')}${this.httpRootDir}z/${result.uniqueSubHash}`});
})
.catch(err => {
logger.error(err);
res.status(500);
res.send(err.message);
});
}
abstract storeItem(item: StoredObject, req: express.Request): Promise<any>;
abstract findUniqueSubhash(hash: string): Promise<any>;
abstract expandId(id: string): Promise<ExpandedShortLink>;
abstract incrementViewCount(id: string): Promise<any>;
}