From f0e0671526acd4179fd32c555ee5edeca5d4de5c Mon Sep 17 00:00:00 2001 From: Stan Date: Mon, 19 Oct 2026 17:33:58 +0500 Subject: [PATCH 1/6] 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. --- src/integrations/aimlapi/config.test.ts | 58 +++++++++++++++++++++++++ src/integrations/aimlapi/config.ts | 42 ++++++++++++++++++ src/services/mcp/headersHelper.test.ts | 35 +++++++++++++++ src/services/mcp/headersHelper.ts | 15 ++++--- 4 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 src/services/mcp/headersHelper.test.ts diff --git a/src/integrations/aimlapi/config.test.ts b/src/integrations/aimlapi/config.test.ts index 2b7dec338a..d6006398cf 100644 --- a/src/integrations/aimlapi/config.test.ts +++ b/src/integrations/aimlapi/config.test.ts @@ -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,58 @@ 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') +}) diff --git a/src/integrations/aimlapi/config.ts b/src/integrations/aimlapi/config.ts index a71b69db9d..b22bd950c5 100644 --- a/src/integrations/aimlapi/config.ts +++ b/src/integrations/aimlapi/config.ts @@ -251,6 +251,48 @@ export function resolveAimlapiAttributionHeaders( return rest } +/** + * AI/ML API MCP server (streamable HTTP). It accepts the same + * `Authorization: Bearer ` 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 + } +} + +/** + * 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>, + url: string, +): Record { + if (!isAimlapiMcpUrl(url)) return { ...headers } + // An undefined base URL selects the canonical (attributed) branch. + return resolveAimlapiAttributionHeaders(headers, undefined) +} + /** * Build the co-branded checkout return URLs the hosted payment page redirects * to after the user pays or cancels. Carrying `sessionToken` + `partnerCheckout=1` diff --git a/src/services/mcp/headersHelper.test.ts b/src/services/mcp/headersHelper.test.ts new file mode 100644 index 0000000000..f4bedd5a37 --- /dev/null +++ b/src/services/mcp/headersHelper.test.ts @@ -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' }) + } +}) diff --git a/src/services/mcp/headersHelper.ts b/src/services/mcp/headersHelper.ts index 7fea3ccdc6..c827ddbcbf 100644 --- a/src/services/mcp/headersHelper.ts +++ b/src/services/mcp/headersHelper.ts @@ -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, + ) } From cf6291e9051450f34688c740a40fb162d840b70f Mon Sep 17 00:00:00 2001 From: Stan Date: Mon, 19 Oct 2026 17:33:58 +0500 Subject: [PATCH 2/6] 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). --- docs/aimlapi-setup.md | 44 +++++- src/cli/handlers/mcp.tsx | 7 + src/plugins/bundled/aimlapiMcp.test.ts | 182 +++++++++++++++++++++++++ src/plugins/bundled/aimlapiMcp.ts | 64 +++++++++ src/plugins/bundled/index.ts | 2 + 5 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/plugins/bundled/aimlapiMcp.test.ts create mode 100644 src/plugins/bundled/aimlapiMcp.ts diff --git a/docs/aimlapi-setup.md b/docs/aimlapi-setup.md index 54b77504b9..baadeed97f 100644 --- a/docs/aimlapi-setup.md +++ b/docs/aimlapi-setup.md @@ -73,10 +73,52 @@ export OPENAI_MODEL="gpt-4o" - `/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 +``` + +You can also open `/plugin` in a session, find the built-in **aimlapi** plugin and enable it there. 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, so a proxy credential is never sent to aimlapi.com. +- The server appears as `plugin:aimlapi:aimlapi`, and its tools appear as `mcp__plugin_aimlapi_aimlapi__`, 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. + ## 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, and checkout) 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, and checkout) 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. diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index 9a2dc89751..030b5b2011 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -248,6 +248,13 @@ export async function mcpRemoveHandler(name: string, options: { // mcp list (lines 4641–4688) export async function mcpListHandler(): Promise { 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(); diff --git a/src/plugins/bundled/aimlapiMcp.test.ts b/src/plugins/bundled/aimlapiMcp.test.ts new file mode 100644 index 0000000000..c8e6d98a59 --- /dev/null +++ b/src/plugins/bundled/aimlapiMcp.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, expect, 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 { getPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js' +import { applyProviderProfileToProcessEnv } from '../../utils/providerProfiles.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from '../../utils/settings/settingsCache.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', + 'CLAUDE_CODE_PROVIDER_ROUTE_ID', +] as const +const originalEnv = Object.fromEntries( + envNames.map(name => [name, process.env[name]]), +) + +function useEnabledPlugins(enabledPlugins?: Record): void { + setSessionSettingsCache({ + settings: enabledPlugins ? { enabledPlugins } : {}, + errors: [], + }) +} + +beforeEach(() => { + for (const name of envNames) delete process.env[name] + registerAimlapiMcpPlugin() +}) + +afterEach(() => { + clearBuiltinPlugins() + resetSettingsCache() + for (const name of envNames) { + const value = originalEnv[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + } +}) + +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('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: 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) +}) + +function withRestoredProcessEnv(run: () => void): void { + const snapshot = { ...process.env } + try { + run() + } finally { + for (const name of Object.keys(process.env)) { + if (!(name in snapshot)) delete process.env[name] + } + Object.assign(process.env, snapshot) + } +} + +test('a saved aimlapi.com provider profile supplies the key without exporting it', () => { + withRestoredProcessEnv(() => { + 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', () => { + withRestoredProcessEnv(() => { + 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) + }) +}) diff --git a/src/plugins/bundled/aimlapiMcp.ts b/src/plugins/bundled/aimlapiMcp.ts new file mode 100644 index 0000000000..83dfa12cf1 --- /dev/null +++ b/src/plugins/bundled/aimlapiMcp.ts @@ -0,0 +1,64 @@ +import { + AIMLAPI_MCP_URL, + isCanonicalAimlapiInferenceBaseUrl, +} from '../../integrations/aimlapi/config.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 (!env.AIMLAPI_API_KEY?.trim()) return false + const baseUrl = env.OPENAI_BASE_URL?.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` or from the /plugin UI. 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 }, + }, + }, + }) +} diff --git a/src/plugins/bundled/index.ts b/src/plugins/bundled/index.ts index c27876ceb1..daae3d3b26 100644 --- a/src/plugins/bundled/index.ts +++ b/src/plugins/bundled/index.ts @@ -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() } From cbe0c0949497e8fe52b3178263da872e3072d59b Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 26 Oct 2026 17:25:46 +0300 Subject: [PATCH 3/6] 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. --- src/plugins/bundled/aimlapiMcp.test.ts | 49 +++++++++++++++++++------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/plugins/bundled/aimlapiMcp.test.ts b/src/plugins/bundled/aimlapiMcp.test.ts index c8e6d98a59..80d8af85a5 100644 --- a/src/plugins/bundled/aimlapiMcp.test.ts +++ b/src/plugins/bundled/aimlapiMcp.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { AIMLAPI_MCP_URL, @@ -6,12 +6,17 @@ import { } from '../../integrations/aimlapi/config.js' import { getMcpServerHeaders } from '../../services/mcp/headersHelper.js' import type { McpHTTPServerConfig } from '../../services/mcp/types.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { getPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js' import { applyProviderProfileToProcessEnv } 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, @@ -30,25 +35,45 @@ const originalEnv = Object.fromEntries( envNames.map(name => [name, process.env[name]]), ) +// 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): void { - setSessionSettingsCache({ - settings: enabledPlugins ? { enabledPlugins } : {}, - errors: [], - }) + settingsForTest = enabledPlugins ? { enabledPlugins } : {} + setSessionSettingsCache({ settings: settingsForTest, errors: [] }) } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('plugins/bundled/aimlapiMcp.test.ts') + settingsForTest = {} + mock.module('../../utils/settings/settings.js', () => ({ + ...realSettings, + getSettings_DEPRECATED: () => settingsForTest, + })) for (const name of envNames) delete process.env[name] registerAimlapiMcpPlugin() }) afterEach(() => { - clearBuiltinPlugins() - resetSettingsCache() - for (const name of envNames) { - const value = originalEnv[name] - if (value === undefined) delete process.env[name] - else process.env[name] = value + try { + clearBuiltinPlugins() + mock.module('../../utils/settings/settings.js', () => realSettings) + resetSettingsCache() + settingsForTest = {} + for (const name of envNames) { + const value = originalEnv[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } finally { + releaseSharedMutationLock() } }) From 8508e0f5393b4ab917fe07bfc95da7d44baca17f Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 29 Oct 2026 13:02:42 +0300 Subject: [PATCH 4/6] 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. --- src/integrations/aimlapi/config.test.ts | 25 +++++++++++++++++++++++++ src/integrations/aimlapi/config.ts | 23 ++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/integrations/aimlapi/config.test.ts b/src/integrations/aimlapi/config.test.ts index d6006398cf..5bad7602c4 100644 --- a/src/integrations/aimlapi/config.test.ts +++ b/src/integrations/aimlapi/config.test.ts @@ -322,3 +322,28 @@ test('MCP attribution restamps a configured partner id instead of combining it', 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' }) +}) diff --git a/src/integrations/aimlapi/config.ts b/src/integrations/aimlapi/config.ts index b22bd950c5..163eafbe8e 100644 --- a/src/integrations/aimlapi/config.ts +++ b/src/integrations/aimlapi/config.ts @@ -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 @@ -276,6 +278,19 @@ export function isAimlapiMcpUrl(url: string): boolean { } } +/** + * 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. Any other Authorization value is returned unchanged. + */ +function singleBearerCredential(value: string): string { + const match = /^Bearer\s+(.+)$/i.exec(value.trim()) + if (!match?.[1]?.includes(',')) 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 @@ -289,8 +304,14 @@ export function resolveAimlapiMcpHeaders( url: string, ): Record { if (!isAimlapiMcpUrl(url)) return { ...headers } + const single: Record = {} + 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(headers, undefined) + return resolveAimlapiAttributionHeaders(single, undefined) } /** From 34cba4bb7499552748ab3901d5559c804db84d9b Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 29 Oct 2026 13:03:25 +0300 Subject: [PATCH 5/6] 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. --- src/plugins/bundled/aimlapiMcp.test.ts | 26 ++++++++++++++++++++++++++ src/plugins/bundled/aimlapiMcp.ts | 5 +++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/plugins/bundled/aimlapiMcp.test.ts b/src/plugins/bundled/aimlapiMcp.test.ts index 80d8af85a5..bb92f78602 100644 --- a/src/plugins/bundled/aimlapiMcp.test.ts +++ b/src/plugins/bundled/aimlapiMcp.test.ts @@ -29,6 +29,7 @@ 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 const originalEnv = Object.fromEntries( @@ -112,6 +113,23 @@ test('enabling the plugin exposes the MCP server with the configured key and att }) }) +test('a pooled AIMLAPI_API_KEY sends only its first key to the MCP server', async () => { + process.env.AIMLAPI_API_KEY = `${TEST_KEY}, aimlapi-second-key-not-real` + 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 + + expect( + (await getMcpServerHeaders('plugin:aimlapi:aimlapi', server!)).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 }) @@ -138,6 +156,14 @@ test('is hidden without an AIMLAPI key even when enabled', () => { 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, diff --git a/src/plugins/bundled/aimlapiMcp.ts b/src/plugins/bundled/aimlapiMcp.ts index 83dfa12cf1..da5e3e97b2 100644 --- a/src/plugins/bundled/aimlapiMcp.ts +++ b/src/plugins/bundled/aimlapiMcp.ts @@ -2,6 +2,7 @@ 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' @@ -25,8 +26,8 @@ const AIMLAPI_MCP_AUTHORIZATION = 'Bearer ${AIMLAPI_API_KEY}' export function hasAimlapiMcpCredential( env: NodeJS.ProcessEnv = process.env, ): boolean { - if (!env.AIMLAPI_API_KEY?.trim()) return false - const baseUrl = env.OPENAI_BASE_URL?.trim() + 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 && From 0cb3e17df9d2c667de34b77f7d70763bfa5996c6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 29 Oct 2026 13:04:03 +0300 Subject: [PATCH 6/6] 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. --- docs/aimlapi-setup.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/aimlapi-setup.md b/docs/aimlapi-setup.md index baadeed97f..4302773387 100644 --- a/docs/aimlapi-setup.md +++ b/docs/aimlapi-setup.md @@ -94,7 +94,8 @@ 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, so a proxy credential is never sent to aimlapi.com. +- 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__`, 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.