Skip to main content
Glama
clement-igonet

apec-mcp

README.md
# APEC MCP Server

MCP (Model Context Protocol) server that exposes the APEC candidate-profile API as tools for any MCP-compatible AI assistant.

---

## API Analysis

### API Type

**Proprietary REST / JSON over HTTPS.**  
Not publicly documented. Discovered via browser network capture (HAR files).  
Base URL: `https://www.apec.fr/cms/webservices/`

### Authentication

Email/password auto-login — no manual cookie handling required.

The server posts credentials to `/.apec-login.do` at first use and caches the resulting session cookies in `~/.apec_session.json`. On 401/403 the session is automatically invalidated and a fresh login is attempted before retrying.

| Env var | Purpose |
|---------|---------|
| `APEC_EMAIL` | APEC account email |
| `APEC_PASSWORD` | APEC account password |
| `APEC_ID_COMPTE_CADRE` | Numeric account ID (from your APEC profile URL) |
| `APEC_ID_PROFIL_CADRE` | Numeric profile ID (from your APEC profile URL) |
| `APEC_SESSION_FILE` | Override session cache path (default: `~/.apec_session.json`) |
| `APEC_COOKIES` | Manual cookie override — takes precedence over auto-login (fallback if DataDome blocks login) |

### Reflection / Introspection

There is no public API schema (no OpenAPI spec, no GraphQL introspection).  
The API was reverse-engineered from three browser sessions:

- `www.apec.fr.har` — initial profile creation
- `www2.apec.fr.har` — portfolio/strengths editing
- `www3.apec.fr.har` — full profile editing (business card, photo upload, reference data)

The backend appears to be a **Java Spring** application (JSESSIONID, Jahia CMS static assets).

---

## API Endpoints

### Profile

| Method | Path | Description |
|--------|------|-------------|
| GET | `/profilCadre/{idCompte}/complete` | Full profile (the canonical object) |
| POST | `/profilCadre/updateExperiencesEtFormations` | Save experiences + education |
| POST | `/profilCadre/updateInformationsComplementaires` | Save job-search preferences |
| POST | `/profilCadre/updateAtouts` | Save skills + strengths |
| POST | `/profilCadre/carteDeVisite` | Save personal / contact info |
| POST | `/profilCadre/validation/{SECTION}` | Validate before saving |
| GET | `/profilCadre/shouldDisplayEncart` | UI display flag |
| GET | `/profilCadre/photoUploadFolders` | Photo upload paths |

### Stats

| Method | Path | Description |
|--------|------|-------------|
| GET | `/profilVu/{idProfil}/nombreVuesDepuisPublication` | Total profile views |
| GET | `/profilVu/{idProfil}/dureePublication` | Days since publication |
| GET | `/profilVu/{idProfil}/tendanceVues` | Views trend (float) |
| POST | `/echangeProfil/count` | Unread recruiter messages |
| POST | `/panierProfil/countInterlocuteursRetenir` | Saved contacts count |

### Search / Autocomplete

| Method | Path | Description |
|--------|------|-------------|
| GET | `/autocompletion/distinctMetierAutocomplete` | Job-title search |
| GET | `/autocompletion/autocomplete` | General search |

### Reference Data

| Method | Path | Description |
|--------|------|-------------|
| POST | `/referentielstatique/presentations/visuels/liste/hierarchie` | Enum lists (869 entries) |
| GET | `/referentielstatique/fonctions/metiers/{id}/organisation` | Métier taxonomy node |
| GET | `/referentielstatique/presentations/code/LISTE_MONDE/visuels` | Country list |

### Identification & CV

| Method | Path | Description |
|--------|------|-------------|
| GET | `/identification/apecuser` | Current user info |
| GET | `/cv/cvFichiersByIdCompte` | Uploaded CV files |
| GET | `/pushRecoOffre/abonnement/cadre/{id}` | Job-recommendation subscription |

### Job Offers

| Method | Path | Description |
|--------|------|-------------|
| POST | `/affiniteOrchestration/matching-candidat` | AI-matched offers for the candidate |
| GET | `/offre/public` | Full offer details by `numeroOffre` |
| POST | `/rechercheAffinite/rebond` | Similar/related offers |
| GET | `/pageEntreprise/public/{id}` | Public company profile |

### Applications & Saved Jobs

| Method | Path | Description |
|--------|------|-------------|
| POST | `/candidature/siteRh` | Submit an APEC-hosted application |
| POST | `/candidature/liste` | List past applications |
| POST | `/candidature/liste/count` | Check if already applied to an offer |
| POST | `/panierOffre/liste` | List saved/bookmarked offers |

---

## Data Model (TypeSpec)

```typespec
import "@typespec/rest";
import "@typespec/http";

using TypeSpec.Rest;
using TypeSpec.Http;

// ── Shared ──────────────────────────────────────────────────────────────

model Audit {
  dateCreation: int64;        // epoch ms
  dateModification: int64 | null;
  utilisateurCreation: string;
  utilisateurModification: string | null;
}

// ── Reference / enum entries ─────────────────────────────────────────────

model ReferenceItem {
  codePresentation: string;
  idNomenclature: int64;
  codeNomenclature: string;
  idOrganisation: int64;
  idOrganisationParent: int64 | null;
  libelle: string;
  niveau: int32;
  ordre: int32;
}

// ── Sub-models ───────────────────────────────────────────────────────────

model Experience {
  intitulePoste: string;
  idNomFonction: int64;
  idNomMetier: int64;
  dateEntree: string;           // ISO 8601
  dateSortie: string | null;
  nomEntreprise: string;
  expNonCadre: boolean;
  numeroOrdre: int32;
  audit: Audit;
}

model Formation {
  intituleFormation: string;
  dateEntree: string;           // ISO 8601
  dateSortie: string | null;
  idNomDiscipline: string;
  idNomNiveau: string;
  organismeFormation: string | null;
  numeroOrdre: int32;
  audit: Audit;
}

enum CompetenceType {
  LANGUE,
  SAVOIR_ETRE,
  TECHNIQUE,
  METIER,
}

model Competence {
  libelle: string;
  type: CompetenceType;
  idNomCompetence: int64;
  idNomNiveau: int64 | null;
  miseEnAvant: boolean;
  idProfilCadre: int64;
  audit: Audit;
  id: int64;
}

model AdressePostale {
  adresseNumeroEtVoie: string;
  adresseCodePostal: string;
  adresseVille: string;
  adresseBatimentImmResidence: string | null;
  adresseComplementAdresse: string | null;
  idPays: int64;
  audit: Audit;
  id: int64;
}

model SouhaitSecteur   { idNomSecteurActivite: int64; audit: Audit; id: int64; }
model SouhaitFonction  { idNomFonction: int64; idNomMetier: int64; audit: Audit; id: int64; }
model SouhaitContrat   { idNomTypeContrat: int64; audit: Audit; id: int64; }
model SouhaitEntreprise{ idNomTailleEntreprise: int64; audit: Audit; id: int64; }
model SouhaitTemps     { idNomTempsTravail: int64; audit: Audit; id: int64; }
model SouhaitMode      { idNomModeTravail: int64; audit: Audit; id: int64; }
model SouhaitLieu      { idNomLieu: int64; distance: int32 | null; audit: Audit; id: int64; }

// ── Full profile ─────────────────────────────────────────────────────────

model ProfilCadre {
  id: int64;
  idProfilCadre: int64;
  idCompteCadre: int64;
  idNomStatut: int64;
  idNomStatutCandidat: int64;

  // Personal info
  idNomCivilite: int64;
  nom: string;
  prenom: string;
  dateNaissance: int64;           // epoch ms
  adressePostale: AdressePostale;
  adresseEmail: string;
  numeroTelephoneMobile: string;
  lienLinkedin: string | null;
  photo: string | null;

  // Professional summary
  metierSouhaite: string;
  objectifProfessionnel: string | null;
  pointsClesProfessionnels: string | null;
  idNomAnneesExperience: int64;
  idNomDelaiDisponibilite: int64;
  remunerationMinimale: float32;
  indicateurMasquerSalaire: boolean;
  pretPourRecrutement: boolean;

  // Preferences
  souhaitsSecteurs: SouhaitSecteur[];
  souhaitsFonctions: SouhaitFonction[];
  souhaitsContrats: SouhaitContrat[];
  souhaitsEnts: SouhaitEntreprise[];
  souhaitsTemps: SouhaitTemps[];
  souhaitsModes: SouhaitMode[];
  souhaitsLieux: SouhaitLieu[];

  // Experiences & formations
  experiencesCles: Experience[];
  formationsCles: Formation[];

  // Skills
  competences: Competence[];
  atouts: string[];
  portfolios: unknown[];

  // CV
  idCvFichier: int64 | null;

  // Completion indicators
  tauxRemplissage: int32;
  indicateurCompletCompetence: boolean;
  indicateurCompletCompMea: boolean;
  indicateurCompletPortfolio: boolean;

  // Versioning
  numeroVersion: int32;
  numeroVersionCadre: int32;
  audit: Audit;
  auditCadre: Audit;
}

// ── User identity ────────────────────────────────────────────────────────

model ApecUser {
  id: string;
  numeroCompte: string;
  nom: string;
  prenom: string;
  email: string;
  cadre: boolean;
  actif: boolean;
  token: string;
  sessionId: string;
}
```

---

## MCP Tools

| Tool | APEC endpoint | Description |
|------|--------------|-------------|
| `get_user_info` | GET `/identification/apecuser` | Authenticated user details |
| `get_profile` | GET `/profilCadre/{id}/complete` | Full profile (use as base for updates) |
| `get_profile_stats` | GET `/profilVu/…` × 3 | Views, duration, trend |
| `get_cv_files` | GET `/cv/cvFichiersByIdCompte` | Uploaded CVs |
| `get_unread_messages_count` | POST `/echangeProfil/count` | Unread recruiter messages |
| `validate_section` | POST `/profilCadre/validation/{SECTION}` | Validate before saving |
| `update_experiences_formations` | POST `/profilCadre/updateExperiencesEtFormations` | Experiences + education |
| `update_informations_complementaires` | POST `/profilCadre/updateInformationsComplementaires` | Job-search preferences |
| `update_atouts` | POST `/profilCadre/updateAtouts` | Skills + strengths |
| `update_carte_de_visite` | POST `/profilCadre/carteDeVisite` | Personal / contact info |
| `search_metiers` | GET `/autocompletion/distinctMetierAutocomplete` | Job-title autocomplete |
| `autocomplete` | GET `/autocompletion/autocomplete` | General search |
| `get_metier_organisation` | GET `/referentielstatique/fonctions/…` | Métier taxonomy |
| `get_reference_lists` | POST `/referentielstatique/…/hierarchie` | Enum reference data |
| `get_matching_jobs` | POST `/affiniteOrchestration/matching-candidat` | AI-matched job offers (paginated) |
| `get_job_offer` | GET `/offre/public` | Full offer details |
| `get_similar_jobs` | POST `/rechercheAffinite/rebond` | Related offers |
| `get_company_info` | GET `/pageEntreprise/public/{id}` | Public company profile |
| `apply_to_job` | POST `/candidature/siteRh` | Submit APEC-hosted application |
| `list_applications` | POST `/candidature/liste` | Past applications (paginated) |
| `check_applied_to_offer` | POST `/candidature/liste/count` | Check if already applied |
| `list_saved_jobs` | POST `/panierOffre/liste` | Saved/bookmarked offers |
| `refresh_session` | POST `/.apec-login.do` | Force re-login and return user info |

### Update pattern

All update endpoints require the **complete profile object** (not a partial patch).

```
profile = get_profile()
profile["experiencesCles"].append({...})
update_experiences_formations(profile)
```

---

## Key Use Case — Fill Profile from LinkedIn or CV

The intended end-to-end workflow:

```
1. [LinkedIn MCP] get_my_profile()           → raw LinkedIn data
   OR parse CV text (PDF / plain text)

2. [APEC MCP]     get_reference_lists([…])  → resolve enum IDs
                  search_metiers(q=…)        → find idNomMetier / idNomFonction

3. AI assistant maps fields:
     LinkedIn title      → metierSouhaite + experiencesCles[].intitulePoste
     LinkedIn positions  → experiencesCles[]
     LinkedIn education  → formationsCles[]
     LinkedIn skills     → competences[]
     LinkedIn location   → adressePostale + souhaitsLieux[]

4. [APEC MCP]     get_profile()                     → fetch current profile
                  update_experiences_formations(…)  → save
                  update_informations_complementaires(…)
                  update_atouts(…)
                  update_carte_de_visite(…)
```

---

## Setup & Run

### Prerequisites

- [uv](https://docs.astral.sh/uv/) installed

### Install

```bash
git clone <repo>
cd apec-mcp
uv sync
cp .env.example .env
# Fill in APEC_EMAIL, APEC_PASSWORD, APEC_ID_COMPTE_CADRE, APEC_ID_PROFIL_CADRE
```

### Local development (SSE)

```bash
uv run apec-mcp               # SSE on http://localhost:8080/sse
```

### MCP client config (stdio — recommended)

Pass credentials directly in the `env` block. Use the absolute path to the venv binary so the config works regardless of which directory the MCP client opens.

```json
{
  "mcpServers": {
    "apec": {
      "command": "/path/to/apec-mcp/.venv/bin/apec-mcp",
      "args": [],
      "env": {
        "MCP_TRANSPORT": "stdio",
        "APEC_EMAIL": "you@example.com",
        "APEC_PASSWORD": "yourpassword",
        "APEC_ID_COMPTE_CADRE": "123456789",
        "APEC_ID_PROFIL_CADRE": "1234567"
      }
    }
  }
}
```

For **Claude Code** place this block in `~/.claude.json` under `mcpServers` so all sessions share it.

### Docker (SSE mode, optional)

```bash
docker build -t apec-mcp:latest .
docker run -d --name apec-mcp-server -p 8081:8080 --env-file .env \
  -e MCP_TRANSPORT=sse apec-mcp:latest
```

Then point your MCP client at `http://localhost:8081/sse`.

> `docker compose` may fail to resolve build-cache image digests in some container-in-container environments (OrbStack socktainer, Docker-in-Docker). Plain `docker run` is more reliable in those setups.

---

## Session Refresh

Session cookies are cached automatically in `~/.apec_session.json` and refreshed on 401/403. No manual action is normally needed.

If auto-login is blocked by DataDome:

1. Open `https://www.apec.fr` → log in manually
2. DevTools → Network → any `/cms/webservices/*` request → Request Headers → `Cookie`
3. Add `APEC_COOKIES=<value>` to `.env` — this overrides auto-login