Two layers wear "MCP gateway" in PMA: the credential-guarded asd-CLI wrapper (
just mcp-asd) and the MCP server (claude_ai_PMA) that exposes per-service tools to AI agents. Together they're how AI agents become first-class operators.
For the operator-facing recipe surface, see /pma/reference/cli/mcp.
This page is the internals — how the gateway works.
just mcp-asd (the credential gateway)The wrapper enforces:
ASD_CLIENT_ID=gwkh — the persistent prod client id. If env has a--client-id override — block any attempt to swap client ids.asd/workspace/audit/mcp-asd.log.Without the wrapper, bun .asd/cli.ts net apply might run with whatever
ASD_CLIENT_ID happens to be in the shell — leading to wrong-tunnel
provisioning. The wrapper's job is to make that impossible.
Per Golden Rule 3, every asd invocation that touches credentials,
tunnels, or Caddy routes goes through this wrapper. Safe-by-default
commands (asd help, asd server, asd automation run) are exempt.
Full guard list: docs/framework/MCP-GATEWAY.md in the asd-pma repo.
claude_ai_PMA MCP serverImplements the MCP protocol over stdio. Each tool is a typed contract:
// packages/mcp-server/tools/redmine_create_ticket.ts
export const redmine_create_ticket = {
name: "redmine_create_ticket",
description: "File a new Redmine issue",
inputSchema: {
type: "object",
properties: {
subject: { type: "string", required: true },
tracker: { type: "string", enum: ["bug", "feature", "task"] },
epic_id: { type: "integer" },
description: { type: "string" },
ai_agent: { type: "string", enum: ["claude-code", "cursor", ...] }
}
},
handler: async (input) => {
const url = `${process.env.REDMINE_URL}/issues.json`;
const headers = {
"X-Redmine-API-Key": process.env.REDMINE_API_KEY,
"X-Redmine-Switch-User": resolveAgentUser(input.ai_agent)
};
const result = await fetch(url, { method: "POST", headers, body: ... });
return { ticket_id: result.id, url: `${process.env.REDMINE_URL}/issues/${result.id}` };
}
};
When an AI agent mutates state, the attribution flows through:
ai_agent: "claude-code" in the tool input.claude-code → Redmine userclaude-code@your-org.local (which must exist in Authentik).X-Redmine-Switch-User: claude-code@....claude-code, not byadmin.X-Zammad-User), n8n (workflow created_by),Why: audit trails show "what did Claude file last week" separately from
"what did Alice file last week". Compliance + sanity.
Valid ai_agent values: claude-code, cursor, github-copilot,
windsurf, aider, continue, codex, manual. Add new values via
PR (one-liner in tools/_agent-resolver.ts).
Tools are auto-discovered from packages/mcp-server/tools/*.ts. On
server start:
// packages/mcp-server/server.ts (sketch)
const toolFiles = await glob("tools/*.ts");
const tools = toolFiles.map(f => require(f).default);
for (const tool of tools) {
mcpServer.registerTool(tool);
}
Adding a new tool = new file in tools/. No central registry to edit.
.env, populated by phase 07b ofinputSchema constrains what the agent can do.redmine_run_arbitrary_sql tool exists..asd/workspace/audit/mcp-server.log with timestamp, tool name,packages/mcp-server/config.ts.In the agent's MCP config (Claude Code example: .claude/settings.json):
{
"mcpServers": {
"pma": {
"command": "bun",
"args": ["/opt/asd-pma/packages/mcp-server/server.ts"],
"env": {
"REDMINE_URL": "${REDMINE_URL}",
"REDMINE_API_KEY": "${REDMINE_API_KEY}"
// etc — pulled from PMA's .env
}
}
}
}
PMA ships a generator: just developer-hook-setup configures Claude
Code's MCP server entry from PMA's .env automatically.
Other self-hosting platforms expose APIs for AI integration. PMA's
distinction:
just recipes. There's nomcp-asd wrapper makes itThis is what enables "Claude can run this incident" or "Cursor can file
its own ticket about the bug it fixed" workflows.
/pma/reference/cli/mcp — the operator-facing recipe surface./asd/reference/cli — what mcp-asd wraps.docs/framework/MCP-GATEWAY.md in the repo — the full guard list + integration spec.redmine_attach_file --json bug discovered + fixed via this gateway.