Skip to main content
Glama

mcp-server

MCP Server in TypeScript that exposes the team's engineering tools —Bitbucket, Jira, Confluence and ArgoCD— to editors with MCP support (VS Code + Copilot, Claude Code, etc.).

It is a thin wrapper: it does not talk to Bitbucket/Jira/Confluence/ArgoCD, nor does it store credentials for those services. It translates everything into HTTP calls against the internal backend eng-api, which already has connections and credentials resolved.

VS Code (dev A) ─┐
VS Code (dev B) ─┼─► MCP Server  ───► eng-api ───► Bitbucket / Jira / Confluence / ArgoCD
VS Code (dev C) ─┘   (este repo)      (credenciales viven aquí)
                     Streamable HTTP      HTTP
                     + API key por dev

Advantage: no dev needs personal tokens for Bitbucket/Jira/Confluence/ArgoCD. Only an API key for this MCP Server, individually revocable.


1. Requirements

  • Node.js ≥ 22

  • Network access to ENG_API_BASE_URL (the eng-api URL)

Related MCP server: Work Integrations MCP

2. Run locally

npm ci
cp .env.example .env      # y rellena los valores (ver sección 3)
npm run dev               # hot-reload, lee .env automáticamente

Other commands:

Command

What it does

npm run dev

Starts in watch mode reading .env

npm run build

Compiles TypeScript to dist/

npm run typecheck

Type-check without emitting

npm start

Starts the compiled version (uses environment variables; this is what runs in the pod)

npm run start:local

Starts the compiled version reading .env

Quick check that it's alive:

curl http://localhost:3000/healthz
# {"status":"ok","server":"mcp-server","version":"0.1.0"}

3. Configuration (.env)

All variables are read from process.env. If a required one is missing or has an invalid value, the process does not start and explains exactly what to fix.

Variable

Required

Default

Description

ENG_API_BASE_URL

Base URL of eng-api, without trailing slash. Must be http(s)://…

MCP_DEV_API_KEYS

Valid API keys for devs against this MCP Server (see §4)

ENG_API_TIMEOUT_MS

10000

Timeout per call to eng-api (1000–120000)

ENG_API_MAX_RETRIES

2

Additional retries on 5xx/429/timeout (0–5)

PORT

3000

HTTP port of the MCP Server

LOG_LEVEL

info

debug | info | warn | error

Example of a failed startup (on purpose):

Configuración inválida: el MCP Server no puede arrancar.
  - Falta la variable obligatoria ENG_API_BASE_URL. Debe apuntar a la URL base de eng-api, ej. https://eng-api.internal.example/api/v1
Revisa tu archivo .env (usa .env.example como plantilla) o el ConfigMap/Secret del Deployment.

4. Authentication: one API key per dev

This auth layer is specific to the MCP Server and independent of what eng-api uses towards the final services.

Generate keys

openssl rand -hex 32     # una por cada persona del equipo

Configure them

MCP_DEV_API_KEYS accepts four formats (minimum 24 characters per key, no duplicates):

MCP_DEV_API_KEYS=<key1>,<key2>                          # CSV simple
MCP_DEV_API_KEYS=alice:<key1>,bob:<key2>                # CSV etiquetado ← recomendado
MCP_DEV_API_KEYS=["<key1>","<key2>"]                    # JSON array
MCP_DEV_API_KEYS={"alice":"<key1>","bob":"<key2>"}      # JSON objeto

Use the tagged format: the tag appears in the MCP Server logs and is propagated to eng-api in the X-Mcp-Dev header, so you can audit who triggered each operation (e.g., an argocd_sync_app) without exposing the key.

Use them

The MCP client must send in each request:

Authorization: Bearer <API_KEY>

(or, alternatively, x-api-key: <API_KEY>). The comparison is timing-safe over SHA-256 digests.

Situation

Response

Without key

401 + message indicating which header is missing

Invalid/revoked key

403 + message indicating what to check

/healthz, /readyz

No auth (for Kubernetes probes)

Revoke someone = remove their key from MCP_DEV_API_KEYS and restart the Deployment. Since each dev has their own, it does not affect others. In production, store the value in a Kubernetes Secret, never in a ConfigMap.

5. Tool catalog

Names have a service prefix and are action-oriented. All support pagination where applicable (page, pageSize from 1 to 100, default 25).

Bitbucket (read-only)

Tool

Arguments

eng-api endpoint

bitbucket_list_prs

workspace, repoSlug, state? (OPEN|MERGED|DECLINED|ALL), author?, page?, pageSize?

GET /bitbucket/repositories/{ws}/{repo}/pull-requests

bitbucket_get_pr

workspace, repoSlug, pullRequestId

GET /bitbucket/repositories/{ws}/{repo}/pull-requests/{id}

bitbucket_get_commits

workspace, repoSlug, branch, sinceCommit?, sinceDate?, page?, pageSize?

GET /bitbucket/repositories/{ws}/{repo}/commits

Jira

Tool

Arguments

eng-api endpoint

jira_search_issues

jql? or simple filters (projectKey?, status?, assignee?, labels?), fields?, page?, pageSize?

POST /jira/issues/search

jira_get_issue

issueKey (format PLAT-4821), fields?, includeComments?

GET /jira/issues/{key}

jira_create_issue ✍️

projectKey, issueType, summary, description?, assignee?, labels?, priority?, parentKey?, extraFields?

POST /jira/issues

Confluence (read-only)

Tool

Arguments

eng-api endpoint

confluence_search_pages

query, spaceKey?, page?, pageSize?

GET /confluence/pages/search

confluence_get_page

pageId, format? (plain|storage|view)

GET /confluence/pages/{id}

ArgoCD

Tool

Arguments

eng-api endpoint

argocd_list_apps

project?, namespace?, syncStatus?, healthStatus?, page?, pageSize?

GET /argocd/applications

argocd_get_app_status

appName

GET /argocd/applications/{name}

argocd_sync_app ⚠️

appName (exact, no default), revision?, prune?, dryRun?, resources?

POST /argocd/applications/{name}/sync

Annotations (hints for the MCP client)

Tool

readOnlyHint

destructiveHint

idempotentHint

openWorldHint

All read-only ones

jira_create_issue ✍️

argocd_sync_app ⚠️

argocd_sync_app requires the exact app name (no wildcards or default values) and prune/dryRun default to false unless explicitly requested.

All eng-api routes live in src/client/routes.ts. If eng-api changes a path, only that file needs to be touched.

6. Configure VS Code (each dev, with their own key)

Create .vscode/mcp.json in your workspace (or the user mcp.json, if you want it in all projects):

{
  "inputs": [
    {
      "type": "promptString",
      "id": "eng-mcp-api-key",
      "description": "Tu API key personal del MCP Server de ingeniería",
      "password": true
    }
  ],
  "servers": {
    "eng": {
      "type": "http",
      "url": "https://<host-del-mcp-server>/mcp",
      "headers": {
        "Authorization": "Bearer ${input:eng-mcp-api-key}"
      }
    }
  }
}

VS Code will ask for the key the first time and store it encrypted; it is never committed. Then, open the chat in Agent mode and you will see the 11 tools under the eng server.

For Claude Code (CLI), the equivalent is:

claude mcp add --transport http eng https://<host-del-mcp-server>/mcp \
  --header "Authorization: Bearer <TU_API_KEY>"

Locally, replace the URL with http://localhost:3000/mcp.

7. Test it with MCP Inspector

npm run build && npm run start:local     # en una terminal
npx @modelcontextprotocol/inspector      # en otra

In the Inspector UI:

  1. Transport Type: Streamable HTTP

  2. URL: http://localhost:3000/mcp

  3. In Authentication, set Header Name to Authorization and the Bearer Token to your API key

  4. ConnectTools tab → List Tools → try any

It can also be tested directly with curl (useful in CI or from a pod):

KEY=<tu-api-key>
curl -s -X POST http://localhost:3000/mcp \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'

Call a tool:

curl -s -X POST http://localhost:3000/mcp \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $KEY" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
        "name":"bitbucket_list_prs",
        "arguments":{"workspace":"acme","repoSlug":"web-frontend","state":"OPEN","pageSize":10}}}'

8. Docker

docker build -t mcp-server:0.1.0 .

docker run --rm -p 3000:3000 \
  -e ENG_API_BASE_URL="https://<eng-api>/api/v1" \
  -e MCP_DEV_API_KEYS="alice:<key1>,bob:<key2>" \
  mcp-server:0.1.0

Multi-stage image on node:22-alpine: the final stage only contains dist/ + production dependencies, runs as user node (no root) and includes a HEALTHCHECK that hits /healthz with Node itself (no curl/wget).

For Kubernetes (manifests are not in this repo):

  • The server is stateless: it does not store sessions in memory, so it scales to N replicas without sticky sessions.

  • Probes: livenessProbeGET /healthz, readinessProbeGET /readyz (both without auth).

  • MCP_DEV_API_KEYS goes in a Secret; ENG_API_BASE_URL and timeouts can go in a ConfigMap.

  • Handles SIGTERM by gracefully shutting down the HTTP server (drain of 10 s max).

9. How to add a new service or tool

The pattern is designed so that adding a service does not touch anything existing. Example with a hypothetical Grafana:

1. Add its routes in src/client/routes.ts:

grafana: {
  listDashboards: (): string => "/grafana/dashboards",
  getDashboard: (uid: string): string => `/grafana/dashboards/${seg(uid)}`,
},

2. Create src/tools/grafana.ts following the same template as the others:

export function registerGrafanaTools(server: McpServer, deps: ToolDeps): void {
  registerEngTool(server, deps, {
    name: "grafana_list_dashboards",              // prefijo de servicio + acción
    title: "Grafana: listar dashboards",
    description: "Qué hace y cuándo usarlo.",
    inputSchema: { query: z.string().optional().describe('Texto a buscar. Ejemplo: "latencia checkout".'),
                   ...paginationShape },
    annotations: readOnlyAnnotations("Grafana: listar dashboards"),
    describeOperation: (args) => `listar dashboards de Grafana`,   // encaja tras "al …"
    execute: (args, { client, context }) =>
      client.get(engApiRoutes.grafana.listDashboards(), {
        query: { query: args.query, ...paginationQuery(args) },
        context,
      }),
  });
}

3. Register it in TOOL_REGISTRARS in src/server.ts:

const TOOL_REGISTRARS = [ …, registerGrafanaTools ];

That's it. registerEngTool already gives you for free: Zod validation, response formatting, truncation of huge payloads, error capture and translation into actionable messages, and logging with requestId.

Style rules for new tools:

  • Name service_action_object, in lowercase.

  • Each schema field with .describe() and a concrete example — that's the only thing the model reads to decide how to call it.

  • Honest annotations: if it writes, readOnlyHint: false; if it can delete something, destructiveHint: true.

  • No dangerous defaults in destructive operations: require exact identifiers.

  • Pagination (...paginationShape + paginationQuery(args)) on everything that returns lists.

  • Never build URLs by hand in tools/: always through engApiRoutes.

10. Error handling

No tool returns a bare "Error 500". Each error includes what failed, what to check and a requestId to cross-reference with eng-api logs. Real example:

No existe el recurso al obtener el estado de la aplicación boom (404). Verifica los identificadores
exactos (workspace/repo, key de issue, id de página, nombre de app) — distinguen mayúsculas. Si los
identificadores son correctos, la ruta de eng-api puede haber cambiado (src/client/routes.ts).
[requestId=8a4bf9e6-…, intentos=1, upstream=GET /argocd/applications/boom]
Respuesta de eng-api: {"error":"application not found"}

Situation

What the MCP Server does

Timeout / network error

Retries with exponential backoff + jitter (ENG_API_MAX_RETRIES), then explains to check ENG_API_BASE_URL / latency

429, 5xx

Retries (respects Retry-After if present) and, if it persists, points to eng-api logs

400 / 422

Does not retry: the parameters are invalid

401 / 403 from eng-api

Clarifies that it is not your MCP API key, but the eng-api credentials/permissions

404

Suggests verifying exact identifiers and the routes in routes.ts

409

State conflict (e.g., an ArgoCD sync already in progress): check the state and retry later

Non-JSON response

Usually a proxy returning HTML: the route probably does not exist

Giant payload

Truncated to 120,000 characters with a notice to reduce pageSize or narrow filters

11. Project structure

src/
├── index.ts                 # entrypoint: Express + Streamable HTTP (stateless), /healthz, /readyz
├── config.ts                # lectura y validación de env vars, fail-fast
├── auth.ts                  # middleware de API key (timing-safe)
├── logger.ts                # logs JSON de una línea, aptos para Cloud Logging
├── server.ts                # createMcpServer(): registra todas las familias de tools
├── client/
│   ├── routes.ts            # ÚNICO sitio con las rutas de eng-api
│   ├── errors.ts            # EngApiError → mensajes accionables
│   └── engApiClient.ts      # fetch + timeout + retry con backoff
└── tools/
    ├── shared.ts            # registerEngTool(), paginación, formateo, errores
    ├── bitbucket.ts  ├── jira.ts  ├── confluence.ts  └── argocd.ts

Design decisions:

  • Streamable HTTP in stateless mode (sessionIdGenerator: undefined, enableJsonResponse: true): a McpServer + transport is created per request. No shared state between devs, no sticky sessions, scales horizontally, and responses are plain JSON (kinder to ingress/proxies than SSE).

  • Only POST /mcp: GET/DELETE respond with 405, because in stateless there is no server→client stream or session to close.

  • Traceability: each request carries an X-Request-Id (respects the client's if sent) and an X-Mcp-Dev with the dev's label, both propagated to eng-api.

Related MCP Connectors

Related MCP Servers