feat(aimlapi): add opt-in MCP plugin with safe credential refresh (#2469)

* feat(aimlapi): attribute MCP requests to mcp.aimlapi.com

Requests from any MCP server whose URL is https on the exact hostname
mcp.aimlapi.com now carry X-AIMLAPI-Source and X-AIMLAPI-Partner-ID,
resolved through the existing resolveAimlapiAttributionHeaders helper so
a configured header cannot replace the partner id. The hook sits in
getMcpServerHeaders, so it covers SSE/HTTP transports for both a
hand-added server and a plugin-provided one. Other MCP servers and
look-alike hosts keep exactly the headers they were configured with.

* feat(aimlapi): opt-in built-in plugin for the AI/ML API MCP server

Add the aimlapi@builtin plugin, which provides the remote MCP server
https://mcp.aimlapi.com/mcp (Toolbox tools_*, image/video/audio
generation, model and account tools). It follows the karpathy-guidelines
precedent: defaultEnabled is false, so the user turns it on explicitly with
`openclaude plugin enable aimlapi@builtin` or the /plugin UI, and a present
key never connects a billed server on its own.

The server authenticates with `Authorization: Bearer ${AIMLAPI_API_KEY}`,
an environment reference expanded at load time, so the key is never
written to settings or an MCP config. An active aimlapi.com profile already
exports its saved key under that name. The plugin is hidden when no key is
set, or when the aimlapi route points at a non-canonical (proxy) base URL,
whose credential the profile exports under the same name.

`openclaude mcp list` now registers built-in plugins first, like the skills
subcommands do, so it lists and health-checks the server the way a session
sees it. Docs cover the plugin and the zero-code `openclaude mcp add`
commands (API key header or OAuth).

* test(aimlapi): pin settings in the MCP plugin test

Bun runs test files in a shared process, and StartupScreen.test.ts leaves its
settings.js mock registered (mock.restore() does not undo mock.module). In the
shard where that file runs before aimlapiMcp.test.ts, getSettings_DEPRECATED
always returned {}, so the opt-in plugin never showed as enabled and two tests
failed.

Mock settings.js in this file the way ModelPicker.test.tsx does: pin
getSettings_DEPRECATED to the test's own settings, restore the real module
after each test, and hold the shared mutation lock while the mock is active.
The plugin code is unchanged.

* fix(aimlapi): send one key to the MCP server when AIMLAPI_API_KEY is a pool

A provider profile can export a comma-separated credential pool as
AIMLAPI_API_KEY, so `Bearer ${AIMLAPI_API_KEY}` could expand to several keys
in one header. For the mcp.aimlapi.com host only, resolveAimlapiMcpHeaders now
keeps the first usable key of a bearer pool (firstUsableCredential), the
same rule #2465 uses for web search. Single keys, non-bearer values and every
other MCP server are left unchanged.

* fix(aimlapi): gate the MCP plugin on a usable key and the OPENAI_API_BASE alias

hasAimlapiMcpCredential now uses firstUsableCredential, so a placeholder
value such as `null` no longer makes the plugin available, and it treats
OPENAI_API_BASE like OPENAI_BASE_URL when withholding a proxy profile's key.
Tests cover the alias, the placeholder and a pooled key reaching the server
as a single Bearer token.

* docs(aimlapi): note the alias and first-key pool rules for the MCP plugin

Mention OPENAI_API_BASE in the proxy rule and that a pooled AIMLAPI_API_KEY
sends only its first usable key to the MCP server.

* test(aimlapi): build startup regression from current source

* fix(aimlapi): normalize single MCP credentials and activation guidance

---------

Co-authored-by: Stan <stan@aimlapi.com>
Co-authored-by: Hugo <hugo@aimlapi.com>
This commit is contained in:
JATMN
2026-10-29 19:44:57 -07:00
committed by GitHub
co-authored by Stan Hugo
parent f235c835d1
commit 42b108d19d
12 changed files with 663 additions and 9 deletions
+44 -1
View File
@@ -86,6 +86,49 @@ for the request and response format.
- `/model` lists chat-capable models discovered from the catalog.
- Send any prompt to confirm responses come back from the selected model.
## AI/ML API MCP server (Toolbox and media generation)
AI/ML API also runs a remote MCP server at `https://mcp.aimlapi.com/mcp`. It adds 19 tools to a session: the Toolbox (`tools_search`, `tools_inspect`, `tools_run`, `tools_run_status`), `image_generate`, `video_generate`, `audio_generate`, `llm_chat`, `embeddings_create`, `job_status`, `job_result`, model lookup (`models_list`, `models_search`, `models_find_best`, `models_compare`, `model_get`), `account_balance`, `account_usage`, and `ping`.
> **Billing:** Toolbox runs (`tools_run`), generations, `llm_chat` and embeddings are charged to your AI/ML API balance. `ping`, `tools_search` and `tools_inspect` are free. The server is never connected unless you turn it on.
### One step: the built-in `aimlapi` plugin
If AI/ML API is already your provider, turn the server on with:
```bash
openclaude plugin enable aimlapi@builtin
```
Use the CLI command above for initial activation. To turn it off again:
```bash
openclaude plugin disable aimlapi@builtin
```
- The plugin signs in with the AI/ML API key you already configured. It reads the key from `AIMLAPI_API_KEY`, which an active aimlapi.com provider profile sets for the session. You can also export the variable yourself. The settings file only stores `"aimlapi@builtin": true`. The key is never written there.
- The plugin is off by default, so having a key does not connect the server. It is also hidden when no `AIMLAPI_API_KEY` is set. It stays hidden when your aimlapi profile points at a proxy base URL (`OPENAI_BASE_URL` or `OPENAI_API_BASE`), so a proxy credential is never sent to aimlapi.com.
- If `AIMLAPI_API_KEY` holds a comma-separated pool, only its first usable key is sent to the MCP server, with no rotation across keys.
- The server appears as `plugin:aimlapi:aimlapi`, and its tools appear as `mcp__plugin_aimlapi_aimlapi__<tool>`, for example `mcp__plugin_aimlapi_aimlapi__tools_search`.
- To check it, run `openclaude mcp list`. You should see `plugin:aimlapi:aimlapi: https://mcp.aimlapi.com/mcp (HTTP) - ✓ Connected`. In a session, `/mcp` shows the same server.
### Without the plugin: `openclaude mcp add`
The standard MCP commands work too. To use your API key, keep the single quotes so that your shell does not expand the variable. OpenClaude expands `${AIMLAPI_API_KEY}` when it connects, so the config file stores the reference and not the key:
```bash
openclaude mcp add --transport http -s user aimlapi https://mcp.aimlapi.com/mcp \
--header 'Authorization: Bearer ${AIMLAPI_API_KEY}'
```
To sign in with your aimlapi.com account (OAuth) instead, leave out the header. `openclaude mcp list` reports **Needs authentication**. In a session, open `/mcp`, select the server and authenticate in the browser:
```bash
openclaude mcp add --transport http -s user aimlapi https://mcp.aimlapi.com/mcp
```
If you configure the server both ways, the hand-added entry wins and the plugin copy is skipped as a duplicate. Note that `openclaude mcp get aimlapi` prints header values after expansion, which includes your key.
## WebFetch through the AI/ML API Toolbox
`WebFetch` normally fetches pages over plain HTTP and converts the HTML to
@@ -124,7 +167,7 @@ export WEB_FETCH_PROVIDER=aimlapi
## Notes
- Model discovery uses the public, unauthenticated `GET /models` endpoint and surfaces only chat-completions models; image, audio, embeddings, and other modalities are intentionally not routed through the coding workflow.
- Every request to AI/ML API (inference, catalog, sign-in/account, checkout, and Toolbox web search) carries the two mandatory attribution headers `X-AIMLAPI-Source: agent/openclaude` and `X-AIMLAPI-Partner-ID`. On the canonical `api.aimlapi.com` endpoint the inference/catalog requests additionally send `X-AIMLAPI-Integration-*` (owner/repo/version) plus `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude`; all attribution headers are stripped when a non-canonical (proxy) base URL is configured, so a third-party host never receives OpenClaude's partner identity.
- Every request to AI/ML API (inference, catalog, sign-in/account, checkout, and Toolbox web search) carries the two mandatory attribution headers `X-AIMLAPI-Source: agent/openclaude` and `X-AIMLAPI-Partner-ID`. On the canonical `api.aimlapi.com` endpoint the inference/catalog requests additionally send `X-AIMLAPI-Integration-*` (owner/repo/version) plus `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude`; all attribution headers are stripped when a non-canonical (proxy) base URL is configured, so a third-party host never receives OpenClaude's partner identity. MCP requests to `mcp.aimlapi.com` (matched by exact hostname, from the plugin or a hand-added server) carry `X-AIMLAPI-Source` and `X-AIMLAPI-Partner-ID`. Other MCP servers never receive them.
- `AIMLAPI_AUTH_URL`, `AIMLAPI_APP_URL`, `AIMLAPI_PAY_URL`, and `AIMLAPI_VERIFICATION_BASE_URL` can point the complete flow at another environment. Guided **new-account** onboarding and key provisioning require the canonical `api.aimlapi.com` inference endpoint, so `AIMLAPI_INFERENCE_URL` must be unset (or left at its default) to create a new account and mint its key — a non-canonical value is rejected there. The existing-key top-up path (paste an aimlapi.com key) instead runs against whatever `AIMLAPI_INFERENCE_URL` is configured. `AIMLAPI_RETURN_URL` overrides only the browser landing page.
- Checkout success is detected by polling. The browser return target is an HTTPS page; OpenClaude does not install a custom URL-scheme handler.
- Usage (`/usage`) reporting is not supported for this provider.
+7
View File
@@ -248,6 +248,13 @@ export async function mcpRemoveHandler(name: string, options: {
// mcp list (lines 4641–4688)
export async function mcpListHandler(): Promise<void> {
logEvent('tengu_mcp_list', {});
// The mcp subcommands skip the session startup that registers built-in
// plugins; register them so an enabled built-in plugin's MCP server (e.g.
// aimlapi@builtin) is listed and health-checked the way a session sees it.
const {
initBuiltinPlugins
} = await import('../../plugins/bundled/index.js');
initBuiltinPlugins();
const {
servers: configs
} = await getAllMcpConfigs();
+71 -1
View File
@@ -1115,7 +1115,7 @@ describe('cli.tsx — background routing behavior', () => {
})
})
describe('Node 24 premature exit regression (issue #1678)', () => {
describe('built Node CLI startup regressions', () => {
let buildDir: string
beforeAll(async () => {
@@ -1150,6 +1150,76 @@ describe('Node 24 premature exit regression (issue #1678)', () => {
if (buildDir) await rm(buildDir, { recursive: true, force: true })
})
async function discoverBeforeInput(aimlapiEnabled: boolean) {
const root = mkdtempSync(join(tmpdir(), 'openclaude-mcp-startup-'))
const cli = join(buildDir, 'dist/cli.mjs')
// Keep stdin open without sending text: discovery must finish before the
// input await, and no setup, MCP connection or model request should start.
const proc = Bun.spawn({
cmd: ['node', cli, '--print', '--debug-to-stderr', '--settings', JSON.stringify({
enabledPlugins: { 'aimlapi@builtin': aimlapiEnabled },
})],
cwd: root,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
TMPDIR: process.env.TMPDIR,
TMP: process.env.TMP,
TEMP: process.env.TEMP,
OPENCLAUDE_CONFIG_DIR: root,
GIT_CEILING_DIRECTORIES: root,
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'http://127.0.0.1:1/v1',
OPENAI_API_KEY: 'unused-test-key',
AIMLAPI_API_KEY: 'unused-aimlapi-test-key',
OPENCLAUDE_DISABLE_EARLY_INPUT: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
},
stdin: 'pipe',
stdout: 'ignore',
stderr: 'pipe',
})
// Each child gets ten seconds; the test leaves five more for teardown.
const deadline = setTimeout(() => proc.kill('SIGKILL'), 10_000)
let stderr = ''
const reader = proc.stderr.getReader()
const decoder = new TextDecoder()
const discoveryPattern = /Found (\d+) plugins \((\d+) enabled, (\d+) disabled\)/
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
stderr += decoder.decode(value, { stream: true })
if (discoveryPattern.test(stderr)) break
}
const discovery = stderr.match(discoveryPattern)
if (!discovery) throw new Error(`Plugin discovery did not complete: ${stderr}`)
expect(stderr).not.toContain('[STARTUP] Running setup()')
return {
total: Number(discovery[1]),
enabled: Number(discovery[2]),
disabled: Number(discovery[3]),
}
} finally {
clearTimeout(deadline)
proc.kill('SIGKILL')
await proc.exited
await reader.cancel()
reader.releaseLock()
rmSync(root, { recursive: true, force: true })
}
}
it('session discovers the enabled aimlapi builtin before delayed pipe input arrives', async () => {
const disabled = await discoverBeforeInput(false)
const enabled = await discoverBeforeInput(true)
// Only aimlapi's setting changes, so unrelated builtins do not affect this
// comparison and cannot satisfy it when aimlapi is missing from discovery.
expect(enabled.total).toBe(disabled.total)
expect(enabled.enabled).toBe(disabled.enabled + 1)
expect(enabled.disabled).toBe(disabled.disabled - 1)
}, 25_000)
it('built CLI stays alive during initialization in interactive mode without premature exit', async () => {
const scriptPath = join(buildDir, 'startup.mjs')
const cliUrl = pathToFileURL(join(buildDir, 'dist/cli.mjs')).href
+83
View File
@@ -2,12 +2,15 @@ import { afterEach, beforeEach, expect, test } from 'bun:test'
import {
AIMLAPI_MANAGED_ATTRIBUTION_HEADER_NAMES,
AIMLAPI_MCP_URL,
DEFAULT_PARTNER_ID,
buildPartnerCheckoutReturnUrls,
buildPartnerReturnUrl,
isAimlapiMcpUrl,
isCanonicalAimlapiInferenceBaseUrl,
isTrustedAimlapiRequestUrl,
resolveAimlapiAttributionHeaders,
resolveAimlapiMcpHeaders,
resolvePartnerId,
resolveEndpoints,
withResolvedPartnerHeader,
@@ -264,3 +267,83 @@ test('proxy attribution strips every managed name spelling and keeps X-Tenant',
expect(headers.get('x-tenant')).toBe('acme')
expect(Object.keys(resolved)).toEqual(['X-Tenant'])
})
test('MCP host gate is an exact https hostname match, never a suffix', () => {
expect(isAimlapiMcpUrl(AIMLAPI_MCP_URL)).toBe(true)
expect(isAimlapiMcpUrl('https://MCP.aimlapi.com/mcp')).toBe(true)
expect(isAimlapiMcpUrl('https://mcp.aimlapi.com:443/other')).toBe(true)
// Sibling aimlapi hosts are not the MCP server.
expect(isAimlapiMcpUrl('https://api.aimlapi.com/v1')).toBe(false)
expect(isAimlapiMcpUrl('https://staging.mcp.aimlapi.com/mcp')).toBe(false)
// Look-alikes, cleartext and garbage are rejected.
expect(isAimlapiMcpUrl('https://mcp.aimlapi.com.attacker.test/mcp')).toBe(false)
expect(isAimlapiMcpUrl('https://evilmcp.aimlapi.com/mcp')).toBe(false)
expect(isAimlapiMcpUrl('https://attacker.test/mcp.aimlapi.com/mcp')).toBe(false)
expect(isAimlapiMcpUrl('http://mcp.aimlapi.com/mcp')).toBe(false)
expect(isAimlapiMcpUrl('not-a-url')).toBe(false)
})
test('MCP headers gain both mandatory attribution headers on the MCP host only', () => {
const resolved = resolveAimlapiMcpHeaders(
{ Authorization: 'Bearer test-key' },
AIMLAPI_MCP_URL,
)
expectSingleManagedAttribution(resolved, {
'X-AIMLAPI-Source': 'agent/openclaude',
'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID,
})
expect(resolved.Authorization).toBe('Bearer test-key')
// Any other MCP server keeps exactly the headers it was configured with.
for (const url of [
'https://mcp.example.test/mcp',
'https://mcp.aimlapi.com.attacker.test/mcp',
'http://mcp.aimlapi.com/mcp',
]) {
expect(resolveAimlapiMcpHeaders({ 'X-Tenant': 'acme' }, url)).toEqual({
'X-Tenant': 'acme',
})
}
})
test('MCP attribution restamps a configured partner id instead of combining it', () => {
const resolved = resolveAimlapiMcpHeaders(
{
'x-aimlapi-partner-id': 'part_attacker',
'X-AIMLAPI-SOURCE': 'agent/attacker',
'X-Tenant': 'acme',
},
AIMLAPI_MCP_URL,
)
expectSingleManagedAttribution(resolved, {
'X-AIMLAPI-Source': 'agent/openclaude',
'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID,
})
expect(new Headers(resolved).get('x-aimlapi-partner-id')).not.toContain(',')
expect(resolved['X-Tenant']).toBe('acme')
})
test('MCP Authorization keeps only the first key of a pooled credential', () => {
expect(
resolveAimlapiMcpHeaders(
{ Authorization: 'Bearer aiml-first-key, aiml-second-key' },
AIMLAPI_MCP_URL,
).Authorization,
).toBe('Bearer aiml-first-key')
// A single key and a non-bearer value pass through unchanged.
expect(
resolveAimlapiMcpHeaders({ Authorization: 'Bearer aiml-only-key' }, AIMLAPI_MCP_URL)
.Authorization,
).toBe('Bearer aiml-only-key')
expect(
resolveAimlapiMcpHeaders({ authorization: 'Basic abc,def' }, AIMLAPI_MCP_URL)
.authorization,
).toBe('Basic abc,def')
// Other MCP servers keep exactly what they were configured with.
expect(
resolveAimlapiMcpHeaders(
{ Authorization: 'Bearer first, second' },
'https://mcp.example.test/mcp',
),
).toEqual({ Authorization: 'Bearer first, second' })
})
+64
View File
@@ -9,6 +9,8 @@
* Override any single URL via the corresponding `AIMLAPI_*_URL` env var.
*/
import { firstUsableCredential } from '../../services/api/credentialPool.js'
export type AimlapiEndpoints = {
/** app/auth service - mints the user access (Bearer) token. */
authBaseUrl: string
@@ -251,6 +253,68 @@ export function resolveAimlapiAttributionHeaders(
return rest
}
/**
* AI/ML API MCP server (streamable HTTP). It accepts the same
* `Authorization: Bearer <AIMLAPI key>` as the inference API and falls back to
* OAuth when no Authorization header is sent.
*/
export const AIMLAPI_MCP_URL = 'https://mcp.aimlapi.com/mcp'
const AIMLAPI_MCP_HOSTNAME = new URL(AIMLAPI_MCP_URL).hostname
/**
* True only for an https URL whose hostname is exactly the AI/ML API MCP host.
* Deliberately not a suffix match: attribution must never reach a look-alike
* (`mcp.aimlapi.com.evil.test`) or a sibling host the user pointed elsewhere.
*/
export function isAimlapiMcpUrl(url: string): boolean {
try {
const parsed = new URL(url)
return (
parsed.protocol === 'https:' &&
parsed.hostname.toLowerCase() === AIMLAPI_MCP_HOSTNAME
)
} catch {
return false
}
}
/**
* An aimlapi provider profile can export a comma-separated credential pool as
* AIMLAPI_API_KEY, so `Bearer ${AIMLAPI_API_KEY}` can expand to several keys.
* The MCP server takes one key: keep the first usable one, as the web search
* backend does, including trimming a single key the availability gate accepts.
* Other Authorization values are returned unchanged.
*/
function singleBearerCredential(value: string): string {
const match = /^Bearer\s+(.+)$/is.exec(value.trim())
if (!match?.[1]) return value
const key = firstUsableCredential(match[1])
return key ? `Bearer ${key}` : value
}
/**
* Stamp the mandatory attribution headers onto an MCP server's request headers
* when (and only when) the server is the AI/ML API MCP host. This covers both
* the built-in `aimlapi` plugin server and a server the user added by hand
* with `openclaude mcp add`. Any other MCP server's headers are returned
* untouched. Managed names are restamped through the same helper the
* inference path uses, so a configured header cannot replace the partner id.
*/
export function resolveAimlapiMcpHeaders(
headers: Readonly<Record<string, string>>,
url: string,
): Record<string, string> {
if (!isAimlapiMcpUrl(url)) return { ...headers }
const single: Record<string, string> = {}
for (const [name, value] of Object.entries(headers)) {
single[name] = name.trim().toLowerCase() === 'authorization'
? singleBearerCredential(value)
: value
}
// An undefined base URL selects the canonical (attributed) branch.
return resolveAimlapiAttributionHeaders(single, undefined)
}
/**
* Build the co-branded checkout return URLs the hosted payment page redirects
* to after the user pays or cancels. Carrying `sessionToken` + `partnerCheckout=1`
+7 -2
View File
@@ -1726,6 +1726,12 @@ async function run(): Promise<CommanderCommand> {
return allowed;
}) : Promise.resolve({});
// Register before the first plugin discovery. Piped input can hold startup
// below while MCP loading memoizes its plugin list.
if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') {
initBuiltinPlugins();
}
// Kick off MCP config loading early (safe - just reads files, no execution).
// Both interactive and -p use getClaudeCodeMcpConfigs (local file reads only).
// The local promise is awaited later (before prefetchAllMcpResources) to
@@ -1854,12 +1860,11 @@ async function run(): Promise<CommanderCommand> {
// since --worktree makes setup() process.chdir() (setup.ts:203), and
// commands/agents need the post-chdir cwd.
const preSetupCwd = getCwd();
// Register bundled skills/plugins before kicking getCommands() — they're
// Register bundled skills before kicking getCommands() — they're
// pure in-memory array pushes (<1ms, zero I/O) that getBundledSkills()
// reads synchronously. Previously ran inside setup() after ~20ms of
// await points, so the parallel getCommands() memoized an empty list.
if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') {
initBuiltinPlugins();
initBundledSkills();
}
const setupPromise = setup(preSetupCwd, permissionMode, allowDangerouslySkipPermissions, worktreeEnabled, worktreeName, tmuxEnabled, sessionId ? validateUuid(sessionId) : undefined, worktreePRNumber, messagingSocketPath);
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import {
AIMLAPI_MCP_URL,
DEFAULT_PARTNER_ID,
} from '../../integrations/aimlapi/config.js'
import { getMcpServerHeaders } from '../../services/mcp/headersHelper.js'
import type { McpHTTPServerConfig } from '../../services/mcp/types.js'
import { snapshotEnv } from '../../test/envSnapshot.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import { getPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js'
import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
import {
applyProviderProfileToProcessEnv,
clearProviderProfileEnvFromProcessEnv,
} from '../../utils/providerProfiles.js'
import {
resetSettingsCache,
setSessionSettingsCache,
} from '../../utils/settings/settingsCache.js'
import type { SettingsJson } from '../../utils/settings/types.js'
import { clearBuiltinPlugins, getBuiltinPlugins } from '../builtinPlugins.js'
import {
AIMLAPI_MCP_PLUGIN_NAME,
hasAimlapiMcpCredential,
registerAimlapiMcpPlugin,
} from './aimlapiMcp.js'
const PLUGIN_ID = `${AIMLAPI_MCP_PLUGIN_NAME}@builtin`
const TEST_KEY = 'aimlapi-test-key-not-real'
const envNames = [
'AIMLAPI_API_KEY',
'OPENAI_BASE_URL',
'OPENAI_API_BASE',
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
] as const
let restoreEnv: () => void = () => {}
// Test files share a process, and an earlier file can leave its own
// settings.js mock registered. Built-in plugins read their enabled state
// through getSettings_DEPRECATED, so pin it to this file's settings and put
// the real module back after each test.
const realSettings = {
...await import(`../../utils/settings/settings.js?real=${Date.now()}-${Math.random()}`),
} as typeof import('../../utils/settings/settings.js')
let settingsForTest: SettingsJson = {}
function useEnabledPlugins(enabledPlugins?: Record<string, boolean>): void {
settingsForTest = enabledPlugins ? { enabledPlugins } : {}
setSessionSettingsCache({ settings: settingsForTest, errors: [] })
}
beforeEach(async () => {
await acquireSharedMutationLock('plugins/bundled/aimlapiMcp.test.ts')
// Profile activation clears managed provider variables as well as setting
// these keys. Identify affected ambient keys without mutating process.env.
const retainedEnv = { ...process.env }
clearProviderProfileEnvFromProcessEnv(retainedEnv)
restoreEnv = snapshotEnv([
...Object.keys(process.env).filter(name => !(name in retainedEnv)),
...envNames,
'CLAUDE_CODE_USE_OPENAI',
'OPENAI_API_KEY',
'OPENAI_MODEL',
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
], { clear: true })
settingsForTest = {}
mock.module('../../utils/settings/settings.js', () => ({
...realSettings,
getSettings_DEPRECATED: () => settingsForTest,
}))
clearPluginCache('aimlapi test setup')
registerAimlapiMcpPlugin()
})
afterEach(() => {
try {
clearPluginCache('aimlapi test cleanup')
clearBuiltinPlugins()
mock.module('../../utils/settings/settings.js', () => realSettings)
resetSettingsCache()
settingsForTest = {}
restoreEnv()
} finally {
releaseSharedMutationLock()
}
})
test('stays disabled when an AIMLAPI key is present but the user has not opted in', () => {
process.env.AIMLAPI_API_KEY = TEST_KEY
useEnabledPlugins()
const { enabled, disabled } = getBuiltinPlugins()
expect(enabled.map(plugin => plugin.name)).not.toContain(
AIMLAPI_MCP_PLUGIN_NAME,
)
expect(disabled.map(plugin => plugin.name)).toContain(AIMLAPI_MCP_PLUGIN_NAME)
})
test('enabling the plugin exposes the MCP server with the configured key and attribution', async () => {
process.env.AIMLAPI_API_KEY = TEST_KEY
useEnabledPlugins({ [PLUGIN_ID]: true })
const plugin = getBuiltinPlugins().enabled.find(
candidate => candidate.name === AIMLAPI_MCP_PLUGIN_NAME,
)
expect(plugin).toBeDefined()
const servers = await getPluginMcpServers(plugin!)
const server = servers?.[`plugin:${AIMLAPI_MCP_PLUGIN_NAME}:aimlapi`] as
| McpHTTPServerConfig
| undefined
expect(server?.type).toBe('http')
expect(server?.url).toBe(AIMLAPI_MCP_URL)
expect(await getMcpServerHeaders('plugin:aimlapi:aimlapi', server!)).toEqual({
Authorization: `Bearer ${TEST_KEY}`,
'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID,
'X-AIMLAPI-Source': 'agent/openclaude',
})
})
test.each([
TEST_KEY,
` ${TEST_KEY} `,
`\n${TEST_KEY}\n`,
`\r\n${TEST_KEY}\r\n`,
`${TEST_KEY}, aimlapi-second-key-not-real`,
`${TEST_KEY},\naimlapi-second-key-not-real`,
`${TEST_KEY},\r\naimlapi-second-key-not-real`,
`,\n${TEST_KEY}, aimlapi-second-key-not-real`,
])('AIMLAPI_API_KEY sends its first usable HTTP-safe key: %j', async credential => {
process.env.AIMLAPI_API_KEY = credential
useEnabledPlugins({ [PLUGIN_ID]: true })
const plugin = getBuiltinPlugins().enabled.find(
candidate => candidate.name === AIMLAPI_MCP_PLUGIN_NAME,
)
const servers = await getPluginMcpServers(plugin!)
const server = servers?.[`plugin:${AIMLAPI_MCP_PLUGIN_NAME}:aimlapi`] as
| McpHTTPServerConfig
| undefined
const headers = await getMcpServerHeaders('plugin:aimlapi:aimlapi', server!)
expect(new Headers(headers).get('authorization')).toBe(`Bearer ${TEST_KEY}`)
})
test('the stored server config references the key instead of embedding it', () => {
process.env.AIMLAPI_API_KEY = TEST_KEY
useEnabledPlugins({ [PLUGIN_ID]: true })
const plugin = getBuiltinPlugins().enabled.find(
candidate => candidate.name === AIMLAPI_MCP_PLUGIN_NAME,
)
const serialized = JSON.stringify(plugin?.mcpServers)
expect(serialized).toContain('${AIMLAPI_API_KEY}')
expect(serialized).not.toContain(TEST_KEY)
})
test('is hidden without an AIMLAPI key even when enabled', () => {
useEnabledPlugins({ [PLUGIN_ID]: true })
const { enabled, disabled } = getBuiltinPlugins()
expect(
[...enabled, ...disabled].map(plugin => plugin.name),
).not.toContain(AIMLAPI_MCP_PLUGIN_NAME)
})
test('withholds a credential the aimlapi route exported for a proxy base URL', () => {
expect(hasAimlapiMcpCredential({ AIMLAPI_API_KEY: TEST_KEY })).toBe(true)
expect(hasAimlapiMcpCredential({ AIMLAPI_API_KEY: ' ' })).toBe(false)
expect(hasAimlapiMcpCredential({ AIMLAPI_API_KEY: 'null' })).toBe(false)
expect(
hasAimlapiMcpCredential({
AIMLAPI_API_KEY: TEST_KEY,
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
OPENAI_API_BASE: 'https://proxy.example.test/v1',
}),
).toBe(false)
expect(
hasAimlapiMcpCredential({
AIMLAPI_API_KEY: TEST_KEY,
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
}),
).toBe(true)
expect(
hasAimlapiMcpCredential({
AIMLAPI_API_KEY: TEST_KEY,
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
OPENAI_BASE_URL: 'https://proxy.example.test/v1',
}),
).toBe(false)
// Another provider's base URL does not make an exported AIMLAPI key a proxy one.
expect(
hasAimlapiMcpCredential({
AIMLAPI_API_KEY: TEST_KEY,
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'openrouter',
OPENAI_BASE_URL: 'https://openrouter.ai/api/v1',
}),
).toBe(true)
})
test('a saved aimlapi.com provider profile supplies the key without exporting it', () => {
applyProviderProfileToProcessEnv({
id: 'provider_aimlapi',
name: 'AI/ML API',
provider: 'aimlapi',
baseUrl: 'https://api.aimlapi.com/v1',
model: 'gpt-4o',
apiKey: TEST_KEY,
})
expect(process.env.AIMLAPI_API_KEY).toBe(TEST_KEY)
expect(hasAimlapiMcpCredential()).toBe(true)
})
test('a saved aimlapi profile retargeted to a proxy never offers its credential', () => {
applyProviderProfileToProcessEnv({
id: 'provider_aimlapi_proxy',
name: 'AI/ML API Proxy',
provider: 'aimlapi',
baseUrl: 'https://proxy.example.test/v1',
model: 'gpt-4o',
apiKey: 'proxy-credential',
})
expect(process.env.AIMLAPI_API_KEY).toBe('proxy-credential')
expect(hasAimlapiMcpCredential()).toBe(false)
})
test('cached discovery withholds a newly activated proxy credential at MCP consumption', async () => {
useEnabledPlugins({ [PLUGIN_ID]: true })
applyProviderProfileToProcessEnv({
id: 'provider_aimlapi', name: 'AI/ML API', provider: 'aimlapi',
baseUrl: 'https://api.aimlapi.com/v1', model: 'gpt-4o', apiKey: TEST_KEY,
})
const discovery = await loadAllPluginsCacheOnly()
const plugin = discovery.enabled.find(candidate => candidate.source === PLUGIN_ID)
expect(plugin).toBeDefined()
const canonical = await getPluginMcpServers(plugin!)
const server = canonical?.['plugin:aimlapi:aimlapi'] as McpHTTPServerConfig
expect((await getMcpServerHeaders('plugin:aimlapi:aimlapi', server)).Authorization)
.toBe(`Bearer ${TEST_KEY}`)
applyProviderProfileToProcessEnv({
id: 'provider_aimlapi_proxy', name: 'AI/ML API Proxy', provider: 'aimlapi',
baseUrl: 'https://proxy.example.test/v1', model: 'gpt-4o', apiKey: 'proxy-credential',
})
expect(await loadAllPluginsCacheOnly()).toBe(discovery)
expect(process.env.AIMLAPI_API_KEY).toBe('proxy-credential')
expect(await getPluginMcpServers(plugin!)).toBeUndefined()
// Explicit manual authentication to the same host remains the user's choice.
expect((await getMcpServerHeaders('manual', {
type: 'http', url: AIMLAPI_MCP_URL,
headers: { Authorization: 'Bearer manual-key' },
})).Authorization).toBe('Bearer manual-key')
})
+65
View File
@@ -0,0 +1,65 @@
import {
AIMLAPI_MCP_URL,
isCanonicalAimlapiInferenceBaseUrl,
} from '../../integrations/aimlapi/config.js'
import { firstUsableCredential } from '../../services/api/credentialPool.js'
import { registerBuiltinPlugin } from '../builtinPlugins.js'
export const AIMLAPI_MCP_PLUGIN_NAME = 'aimlapi'
export const AIMLAPI_MCP_SERVER_NAME = 'aimlapi'
/**
* The plugin authenticates with the AI/ML API key the user already configured.
* It is an environment reference, expanded when the server config is loaded,
* so the key itself is never copied into settings or an MCP config file. An
* active aimlapi.com provider profile exports its saved key as
* AIMLAPI_API_KEY for the session, and a user can export it directly.
*/
const AIMLAPI_MCP_AUTHORIZATION = 'Bearer ${AIMLAPI_API_KEY}'
/**
* Whether the session holds an AI/ML API key this plugin may send to the MCP
* host. When the aimlapi route is retargeted to a non-canonical base URL (a
* user proxy), the profile exports that proxy's credential under the same
* variable name, so it is withheld rather than forwarded to aimlapi.com.
*/
export function hasAimlapiMcpCredential(
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (!firstUsableCredential(env.AIMLAPI_API_KEY)) return false
const baseUrl = env.OPENAI_BASE_URL?.trim() || env.OPENAI_API_BASE?.trim()
if (
env.CLAUDE_CODE_PROVIDER_ROUTE_ID === 'aimlapi' &&
baseUrl &&
!isCanonicalAimlapiInferenceBaseUrl(baseUrl)
) {
return false
}
return true
}
/**
* Opt-in built-in plugin exposing the AI/ML API MCP server (Toolbox `tools_*`,
* image/video/audio generation, model and account tools). Disabled by default:
* Toolbox runs and generations are billed to the user's AI/ML API balance, so
* a key being present must never connect it on its own. Enable it with
* `openclaude plugin enable aimlapi@builtin` for initial activation. Attribution
* headers are added for the MCP host by `getMcpServerHeaders`.
*/
export function registerAimlapiMcpPlugin(): void {
registerBuiltinPlugin({
name: AIMLAPI_MCP_PLUGIN_NAME,
description:
'AI/ML API MCP server: Toolbox tools, image/video/audio generation, model and account tools. Uses your AIMLAPI_API_KEY; tool runs and generations are billed to your AI/ML API balance.',
version: '1.0.0',
defaultEnabled: false,
isAvailable: () => hasAimlapiMcpCredential(),
mcpServers: {
[AIMLAPI_MCP_SERVER_NAME]: {
type: 'http',
url: AIMLAPI_MCP_URL,
headers: { Authorization: AIMLAPI_MCP_AUTHORIZATION },
},
},
})
}
+2
View File
@@ -14,6 +14,7 @@
* 2. Call that helper from initBuiltinPlugins()
*/
import { registerAimlapiMcpPlugin } from './aimlapiMcp.js'
import { registerKarpathyGuidelinesPlugin } from './karpathyGuidelines.js'
/**
@@ -21,4 +22,5 @@ import { registerKarpathyGuidelinesPlugin } from './karpathyGuidelines.js'
*/
export function initBuiltinPlugins(): void {
registerKarpathyGuidelinesPlugin()
registerAimlapiMcpPlugin()
}
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from 'bun:test'
import {
AIMLAPI_MCP_URL,
DEFAULT_PARTNER_ID,
} from '../../integrations/aimlapi/config.js'
import { getMcpServerHeaders } from './headersHelper.js'
test('requests to the AI/ML API MCP host carry the attribution headers', async () => {
const headers = await getMcpServerHeaders('aimlapi', {
type: 'http',
url: AIMLAPI_MCP_URL,
headers: { Authorization: 'Bearer test-key' },
})
expect(headers).toEqual({
Authorization: 'Bearer test-key',
'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID,
'X-AIMLAPI-Source': 'agent/openclaude',
})
})
test('other MCP servers never receive AI/ML API attribution', async () => {
for (const url of [
'https://mcp.example.test/mcp',
'https://mcp.aimlapi.com.attacker.test/mcp',
]) {
const headers = await getMcpServerHeaders('other', {
type: 'http',
url,
headers: { Authorization: 'Bearer other-key' },
})
expect(headers).toEqual({ Authorization: 'Bearer other-key' })
}
})
+10 -5
View File
@@ -1,4 +1,5 @@
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { resolveAimlapiMcpHeaders } from '../../integrations/aimlapi/config.js'
import { checkHasTrustDialogAccepted } from '../../utils/config.js'
import { logAntError } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
@@ -131,9 +132,13 @@ export async function getMcpServerHeaders(
const dynamicHeaders =
(await getMcpHeadersFromHelper(serverName, config)) || {}
// Dynamic headers override static headers if both are present
return {
...staticHeaders,
...dynamicHeaders,
}
// Dynamic headers override static headers if both are present. Requests to
// the AI/ML API MCP host (exact hostname) also carry its attribution headers.
return resolveAimlapiMcpHeaders(
{
...staticHeaders,
...dynamicHeaders,
},
config.url,
)
}
@@ -1,4 +1,5 @@
import { join } from 'path'
import { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js'
import { expandEnvVarsInString } from '../../services/mcp/envExpansion.js'
import {
type McpServerConfig,
@@ -601,6 +602,13 @@ export async function getPluginMcpServers(
return undefined
}
// Discovery is cached, but credentials are expanded afresh below. A
// provider switch can make a builtin's current credential ineligible.
if (plugin.isBuiltin) {
const definition = getBuiltinPluginDefinition(plugin.name)
if (definition?.isAvailable && !definition.isAvailable()) return undefined
}
// Resolve environment variables. Same per-server try/catch as
// extractMcpServersFromPlugins above: a partial saved channel config
// (plugin update added a required field) would make