mirror of
https://github.com/compiler-explorer/compiler-explorer.git
synced 2026-09-10 16:57:56 -04:00
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>
162 lines
6.3 KiB
TypeScript
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>;
|
|
}
|