I have two FormLM environments: staging (staging.formlm.me) and production (formlm.me). I use staging for testing new forms, new field configurations, and AI-driven builds. Production is where real respondents fill out real assessments.
The formlm-cli supports multiple profiles, so I have both configured:
{
"profiles": [
{
"name": "staging",
"url": "https://staging.formlm.me",
"token": "staging-token-here",
"active": true
},
{
"name": "production",
"url": "https://formlm.me",
"token": "prod-token-here"
}
]
}
The active flag determines which profile the CLI uses by default. I keep staging active because that's where I do most of my AI-driven builds.
This setup works fine — until it doesn't.
What Happened
I was testing a new employee engagement survey. I'd been building it on staging all morning, iterating with Claude on the field structure and scoring. After about 20 iterations, the form was ready. I told Claude:
"This form is ready. Publish it and give me the production URL so I can share it with the team."
Claude called share_publish and share_url. The URL came back:
https://staging.formlm.me/s/abc123
That's the staging URL. Not production.
Claude had published to staging because staging was the active profile. It didn't switch to production — partly because I didn't tell it to, and partly because the MCP tools don't have a "switch profile" operation.
I hadn't noticed until I sent the link to my team and someone said "is this the staging environment?"
Why the MCP Tools Don't Switch Profiles
Looking at the config code, profile management is handled at the CLI level, not the MCP level:
export function getBaseUrl(): string {
return process.env.FORMLM_BASE_URL || getProfile(runtimeProfile)?.url || getActiveProfile()?.url || 'https://formlm.me';
}
export function getToken(profileName?: string): string {
return process.env.FORMLM_TOKEN || getProfile(profileName || runtimeProfile)?.token || getActiveProfile()?.token || '';
}
There's a priority chain:
-
FORMLM_BASE_URL/FORMLM_TOKENenvironment variables (highest) -
runtimeProfile(set by--profileglobal CLI option) -
activeProfile(the profile flagged as active in config.json) - Hardcoded defaults (lowest)
The MCP server uses getBaseUrl() and getToken() without any profile parameter. So it always uses the active profile — or the environment variables if they're set.
There's no MCP tool to switch profiles. No config_switch_profile, no config_set_active. The AI can't change which environment it's targeting.
The Token Priority Trap
Here's where it gets more insidious. The priority chain means:
- If
FORMLM_TOKENis set as an environment variable, it overrides everything — regardless of which profile is active. - If
FORMLM_TOKENis not set, the runtime profile is checked next. - If neither is set, the active profile is used.
I had FORMLM_TOKEN set in my shell environment from a previous session. It was a production token. But my active profile was staging. So:
-
getBaseUrl()returnedstaging.formlm.me(from the active profile) -
getToken()returned the production token (from the environment variable)
I was sending a production token to the staging server. The staging server rejected it. The error message said "Unauthorized." Claude saw the error and tried auth_login with my staging credentials — which worked, but created a new token that overwrote the production token in my config.
Now my staging profile had a production token, my production profile still had the old (valid) production token, and my environment variable was still set to the production token. Three places storing tokens, two of them stale, one of them wrong.
The Config File Permissions
One thing I got right: the config file is stored with 0o600 permissions:
export function saveConfig(config: Config): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
}
~/.formlm/config.json is readable only by the owner. That's good — tokens are sensitive. But it also means if something goes wrong with the config, I can't easily inspect it from a different user context (like a CI/CD pipeline).
How I Fixed It
The fix was a three-step manual process:
- Unset
FORMLM_TOKENfrom my shell environment - Manually edit
~/.formlm/config.jsonto restore the correct staging token - Switch the active profile to production before publishing
The third step is the one I want to automate. I need an MCP tool that lets the AI switch profiles:
server.tool('config_set_active', 'Switch the active profile', {
name: z.string().describe('Profile name'),
}, async (params) => {
const found = setActiveProfile(params.name);
return { content: [{ type: 'text' as const, text: found ? `✅ Active profile: ${params.name}` : `❌ Profile not found: ${params.name}` }] };
});
But I haven't added it yet. The concern is security — should an AI be able to switch to a production environment? In my case, I trust Claude to do what I ask. But if someone else uses this CLI with their own Claude instance, they might not want the AI to be able to switch to production and start creating forms there.
The Safer Approach: Per-Command Profile
The CLI already supports a --profile flag that sets the runtime profile for a single command:
formlm-cli --profile production share publish --app abc123
This sets runtimeProfile for the duration of the command, without changing the active profile in the config. It's the safest approach — you explicitly specify which environment for each operation.
But the MCP server doesn't expose this. Every MCP tool call goes through execCommand(cmd) without a profile parameter:
export async function execCommand(cmd: string, profileName?: string): Promise<ExecResult> {
const baseUrl = getBaseUrl();
const token = getToken(profileName);
// ...
}
The profileName parameter exists but is never passed by the MCP tools. So every MCP tool call uses the active profile.
What I Should Do
Here's my plan:
-
Add
config_list_profiles— let the AI see what profiles exist (names and URLs, but not tokens) -
Add
config_set_active— let the AI switch profiles, but only between non-production profiles by default -
Add an
appId-scoped profile — store the profile used to create each app, and warn the AI when it tries to operate on an app from a different profile
The third one is the most interesting. If I create an app on staging, the app ID should remember it belongs to staging. When Claude tries to publish that app ID from a production profile, the tool should warn: "This app was created on staging. Are you sure you want to publish it on production?"
That's a cross-cutting concern — it requires the server to track which environment created each app, not just the CLI. But it would have prevented this entire incident.
The Lesson: Environment Leaking Is Silent
The worst part about this incident wasn't the error — it was the silence. When I sent a production token to staging, the staging server rejected it. That's loud — you get an error immediately. But when the active profile was staging and I asked Claude to "publish to production," Claude published to staging without any error. The operation succeeded. The URL was valid. It just pointed to the wrong server.
Environment leaking is silent when the operation succeeds on the wrong environment. There's no error to catch. The only way to notice is to read the URL — and if you're trusting the AI to handle the details, you might not check.
The fix isn't just adding tools to switch profiles. It's making the environment visible in every response. When Claude calls share_publish, the response should include the environment:
✅ Published on staging.formlm.me. URL: https://formlm.me/s/abc123
Not just the URL. The environment name. So you can't accidentally share a staging link thinking it's production.
I haven't implemented this yet. But after this incident, it's the next thing on my list.
The formlm-cli supports multi-profile management with per-profile tokens and URLs. Open source at github.com/formlm/cli — try the platform at formlm.me.
Top comments (0)