Skip to main content
Glama
README.md
# career-mcp-server

![CI](https://github.com/mubashirnaeemj/career-mcp-server/actions/workflows/ci.yml/badge.svg)
![Version](https://img.shields.io/badge/version-1.0.0-blue)
![Python](https://img.shields.io/badge/python-3.12-blue)
![FastAPI](https://img.shields.io/badge/FastAPI-production--ready-009688)
![License](https://img.shields.io/badge/license-MIT-green)

A production-grade **AI Career MCP Server** — a FastAPI application that exposes career-oriented
tools to LLM clients via the [Model Context Protocol](https://modelcontextprotocol.io) (MCP).

> **v1.0.0** — the first stable release. It contains Phase 1 (foundation), Phase 2 (GitHub
> integration), Phase 3 (AI career intelligence & resume engine), and Phase 4 (AI platform, RAG,
> memory & advanced MCP). LinkedIn integration is not implemented yet — that's Phase 5, built on
> top of this foundation. See [CHANGELOG.md](CHANGELOG.md) and
> [RELEASE_NOTES_v1.0.0.md](RELEASE_NOTES_v1.0.0.md) for release details.

---

## Overview

`career-mcp-server` is designed as a long-lived, extensible service:

- A **FastAPI** HTTP layer for health checks, versioning, and REST endpoints.
- An **MCP server**, built on the official MCP Python SDK, that exposes tools an LLM client can
  call directly.
- A clean separation between **API**, **core** (cross-cutting concerns), **MCP**, **integrations**,
  **ai**, **resume**, **career**, **services**, **models**, and **schemas**, so future features
  plug in without restructuring the app.
- A **GitHub integration** (Phase 2): an async REST client, typed Pydantic models, a caching
  service layer, and the same functionality exposed identically through both REST endpoints and
  MCP tools.
- A **resume & AI career intelligence engine** (Phase 3): PDF upload and parsing, AI-powered
  analysis and scoring, resume-to-job matching, and AI-generated rewrites, cover letters, and
  interview prep — all behind a provider-agnostic AI abstraction, again exposed identically
  through REST and MCP.
- An **AI platform with RAG, memory & advanced MCP** (Phase 4): six interchangeable LLM providers,
  a pluggable embeddings + vector store layer (ChromaDB), a knowledge base built from indexed
  resumes and GitHub repositories, retrieval-augmented chat with conversation memory and streaming,
  a multi-step tailored-application orchestration pipeline, and the MCP **Resources** and
  **Prompts** primitives alongside tools.

Phase 1 registered a single demonstration MCP tool — `ping`, which returns `"pong"` — solely to
prove the MCP integration initializes and works end-to-end. Phase 2 added 22 GitHub tools, Phase 3
added 17 more (13 resume + 4 career), and Phase 4 adds 12 more (4 RAG chat + 4 knowledge + 3
session + 1 orchestration) — 52 MCP tools in total, plus 7 MCP Resources and 8 MCP Prompts.

## Architecture

```
Client (HTTP)                         Client (MCP)
     │                                      │
     ▼                                      ▼
 FastAPI app  ───────────────────►   MCPServer instance
     │  middleware, exception             │
     │  handlers, routers                 │  tools / resources / prompts
     ▼                                     ▼
 app/api/routers/*        app/mcp/{server,registry,tools/*,resources,prompts}.py
     │                                     │
     └───────────────┬─────────────────────┘
                      ▼
     ┌───────────┬────────────┬────────────┬─────────────┬───────────────┐
     ▼            ▼            ▼            ▼             ▼               ▼
 GitHubService ResumeService CareerService RAGService  KnowledgeService OrchestrationService
 (caching,    (storage,     (advice,      (chat +      (indexing +      (resume → skills →
  validation)  parsing,      summaries,    retrieval    semantic         projects → GitHub →
     │         analysis,     bio           + memory)    search)          tailored resume →
     ▼         matching,     rewriting)       │             │            cover letter →
 GitHubClient  AI rewrite)      │              │             ▼            interview prep)
     │             │            │              │      VectorStore (abstract)
     ▼             ▼            ▼              │             │
 GitHub REST   ResumeStorage  AIProvider (abstract) ◄─────────┘     ChromaVectorStore
   API         (local FS)          │                                       │
                                   ▼                              app/knowledge/chunking.py
                    ┌──────────────┼───────────────┬──────────────────┐    (resume + GitHub
                    ▼              ▼               ▼                  ▼     document chunking)
              OpenAIProvider AnthropicProvider GeminiProvider  OllamaProvider
              AzureProvider  OpenRouterProvider              EmbeddingProvider (abstract)
                                                                       │
                                                      ┌────────────────┼────────────────┐
                                                      ▼                ▼                ▼
                                              OpenAIEmbedding  OllamaEmbedding  SentenceTransformers
     │
     ▼
 SessionManager / ConversationStore (app/memory/*)  ──►  used by RAGService for chat history
     │
     ▼
 app/core/*  (DI, exceptions, middleware, metrics, startup/shutdown, cache)
     │
     ▼
 app/config/*  (settings, logging)  ◄── app/services, app/models, app/schemas, app/utils
```

Every REST route and MCP tool is a thin pass-through to the same service classes — there is
exactly one implementation of business logic, validation, caching, and error handling per domain,
regardless of which transport a client uses.

Design principles:

- **Dependency injection** — routes and services depend on `get_settings()` / `get_logger()` /
  `get_github_service()` / `get_resume_service()` / `get_career_service()` / `get_ai_provider()` /
  `get_embedding_provider()` / `get_vector_store()` / `get_knowledge_service()` /
  `get_session_manager()` / `get_rag_service()` / `get_orchestration_service()` rather than
  importing configuration or integrations directly.
- **SOLID / clean architecture** — each layer has one responsibility; every service inherits from
  the shared `BaseService` base class so domain code stays consistent across integrations.
- **Provider-agnostic AI** — every AI-backed service depends on the abstract `AIProvider` interface
  (`app/ai/provider.py`), never on a specific SDK directly. Swapping `LLM_PROVIDER` swaps the
  implementation everywhere at once across 6 supported providers (see
  [Supported Providers](#supported-providers)); embeddings and the vector store follow the same
  pattern (`EmbeddingProvider`, `VectorStore`).
- **Lazy provider construction** — AI-backed services accept a *factory callable*
  (`Callable[[], AIProvider]`), not a pre-built instance, so the app and every non-AI code path
  work with zero API keys configured; only calling an AI-dependent method constructs (and can
  fail on) the provider. Applied consistently to `ResumeService`, `CareerService`,
  `KnowledgeService`, `RAGService`, and `OrchestrationService`.
- **Structured errors** — every handled failure, from either transport, returns the same JSON
  error envelope (`{"success": false, "error": {...}}`) or the equivalent MCP `is_error` result.
- **Typed data only** — no integration ever returns raw JSON or unstructured AI text; every method
  returns a validated Pydantic model or a list of them.
- **Async-first** — all I/O-bound code paths are `async def`, including the GitHub and AI clients.
- **Graceful degradation** — the app starts and resume storage (list/get/delete) works fully even
  with no `OPENAI_API_KEY` configured; only AI-dependent calls fail, cleanly, when actually
  invoked. See [Graceful Degradation](#graceful-degradation).

## Tech Stack

| Layer | Technology |
|---|---|
| Language / runtime | Python 3.12+ |
| Web framework | [FastAPI](https://fastapi.tiangolo.com/) + Uvicorn (ASGI) |
| MCP | Official [MCP Python SDK](https://modelcontextprotocol.io) — tools, resources, and prompts |
| Data validation | Pydantic v2 + Pydantic Settings |
| HTTP client | httpx (async, used for GitHub, Ollama, and streaming) |
| Logging | Loguru (console + rotating file sinks) |
| PDF parsing | pypdf |
| LLM SDKs | `openai`, `anthropic`, `google-genai` (+ plain httpx for Ollama) |
| Vector database | ChromaDB (embedded/persistent mode) |
| Testing | pytest, pytest-asyncio, pytest-cov |
| Lint / types | Ruff (lint), mypy (strict mode) |
| Containerization | Docker (multi-stage, non-root) + Docker Compose |
| CI/CD | GitHub Actions |

## Folder Structure

```
career-mcp-server/
├── app/
│   ├── main.py                 # FastAPI app factory + entry point
│   ├── config/
│   │   ├── settings.py         # Pydantic Settings (env-driven configuration)
│   │   └── logging.py          # Loguru sinks: console + rotating file logs
│   ├── api/
│   │   └── routers/
│   │       ├── health.py       # GET /health
│   │       ├── github.py       # 22 REST endpoints for the GitHub integration
│   │       ├── resume.py       # 13 REST endpoints for resume management & AI features
│   │       ├── career.py       # 4 REST endpoints for general career intelligence
│   │       ├── chat.py         # POST /chat, POST /chat/stream (RAG, SSE streaming)
│   │       ├── sessions.py     # POST/GET /sessions, GET/DELETE /sessions/{id}
│   │       ├── knowledge.py    # POST /knowledge/{index,reindex,search}, GET /knowledge/status
│   │       ├── search.py       # POST /search (top-level semantic search alias)
│   │       ├── metrics.py      # GET /metrics (observability snapshot)
│   │       ├── orchestration.py # POST /orchestration/tailor-application
│   │       └── base.py         # Aggregates all routers
│   ├── core/
│   │   ├── exceptions.py       # Exception hierarchy + handlers
│   │   ├── dependencies.py     # DI entry points (get_settings, get_logger)
│   │   ├── cache.py            # Generic in-memory TTL cache
│   │   ├── middleware.py       # Request ID, timing, logging, CORS, metrics, etc.
│   │   ├── metrics.py          # Dependency-free counters/histograms + GET /metrics
│   │   ├── startup.py          # Graceful startup hook
│   │   └── shutdown.py         # Graceful shutdown hook
│   ├── mcp/
│   │   ├── server.py           # Builds the MCPServer instance, registers tools/resources/prompts
│   │   ├── registry.py         # Tool registry + demo `ping` tool
│   │   ├── resources.py        # 7 MCP Resources (resume, portfolio, github, career, skills, ...)
│   │   ├── prompts.py          # 8 MCP Prompts (reusable prompt templates for MCP clients)
│   │   └── tools/
│   │       ├── github_tools.py # 22 GitHub MCP tools (thin wrappers over GitHubService)
│   │       ├── resume_tools.py # 13 resume MCP tools (thin wrappers over ResumeService)
│   │       ├── career_tools.py # 4 career MCP tools (thin wrappers over CareerService)
│   │       ├── rag_tools.py    # 4 RAG chat MCP tools (thin wrappers over RAGService)
│   │       ├── knowledge_tools.py # 4 knowledge-base MCP tools (search, status, reindex, embed)
│   │       ├── session_tools.py   # 3 conversation-session MCP tools
│   │       └── orchestration_tools.py # 1 tailored-application-pipeline MCP tool
│   ├── integrations/
│   │   └── github/
│   │       ├── client.py       # Async httpx client: auth, retries, pagination, rate limits
│   │       ├── models.py       # Typed GitHub resource models (User, Repository, Commit, ...)
│   │       ├── errors.py       # Maps GitHub HTTP errors to application exceptions
│   │       ├── validation.py   # owner/repo/pagination/search input validation
│   │       ├── service.py      # GitHubService: business logic + caching
│   │       └── dependencies.py # get_github_client() / get_github_service() DI factories
│   ├── ai/
│   │   ├── provider.py         # AIProvider abstract interface (complete/complete_json/stream)
│   │   ├── base_provider.py    # Shared scaffolding for concrete providers
│   │   ├── openai_provider.py  # OpenAIProvider + ChatCompletionsMixin (shared OpenAI-wire-format
│   │   │                       # logic reused by Azure and OpenRouter)
│   │   ├── azure_provider.py   # AzureProvider (Azure OpenAI)
│   │   ├── openrouter_provider.py # OpenRouterProvider
│   │   ├── anthropic_provider.py  # AnthropicProvider (Claude models)
│   │   ├── gemini_provider.py  # GeminiProvider (Google Gemini)
│   │   ├── ollama_provider.py  # OllamaProvider (local models via plain httpx)
│   │   ├── factory.py          # get_ai_provider() DI factory, keyed by LLM_PROVIDER
│   │   ├── sanitize.py         # Prompt-injection mitigations: input sanitizing, content fencing
│   │   ├── prompts.py          # Every prompt template used anywhere, centralized
│   │   └── embeddings/
│   │       ├── provider.py     # EmbeddingProvider abstract interface (embed/embed_one)
│   │       ├── openai_embeddings.py           # OpenAIEmbeddingProvider
│   │       ├── ollama_embeddings.py            # OllamaEmbeddingProvider
│   │       ├── sentence_transformers_embeddings.py # Local, optional-dependency provider
│   │       └── factory.py      # get_embedding_provider() DI factory, keyed by EMBEDDING_PROVIDER
│   ├── resume/
│   │   ├── models.py           # Parsed-resume domain models (ContactInfo, Experience, ...)
│   │   ├── schemas.py          # REST/MCP request & response schemas
│   │   ├── validators.py       # Upload/filename/resume-id validation
│   │   ├── storage.py          # ResumeStorage abstraction + LocalResumeStorage
│   │   ├── parser.py           # PDF text extraction + AI-powered structured parsing
│   │   ├── job_parser.py       # AI-powered job description parsing
│   │   ├── service.py          # ResumeService: storage, analysis, matching, AI rewriting
│   │   └── dependencies.py     # get_resume_service() DI factory
│   ├── career/
│   │   ├── schemas.py          # REST/MCP request & response schemas
│   │   ├── service.py          # CareerService: advice, summaries, bio rewriting
│   │   └── dependencies.py     # get_career_service() DI factory
│   ├── vectorstore/
│   │   ├── models.py           # VectorRecord, SearchResult
│   │   ├── store.py            # VectorStore abstract interface
│   │   ├── chroma_store.py     # ChromaVectorStore (ChromaDB, run via asyncio.to_thread)
│   │   └── factory.py          # get_vector_store() DI factory, keyed by VECTOR_DB
│   ├── knowledge/
│   │   ├── chunking.py         # Recursive, overlap-aware text chunking for indexing
│   │   ├── resume_indexer.py   # Builds section-tagged chunks from a stored resume
│   │   ├── github_indexer.py   # Builds chunks from a repository's README/languages/topics/...
│   │   ├── schemas.py          # REST/MCP request & response schemas
│   │   ├── service.py          # KnowledgeService: index, status, reindex, semantic search
│   │   └── dependencies.py     # get_knowledge_service() DI factory
│   ├── memory/
│   │   ├── models.py           # ChatMessage, ConversationSession
│   │   ├── store.py            # ConversationStore abstract interface + in-memory implementation
│   │   ├── session_manager.py  # SessionManager: create/resume/expire/append/history
│   │   ├── context_builder.py  # Builds conversation + retrieval context strings for prompts
│   │   ├── schemas.py          # REST/MCP request & response schemas
│   │   └── dependencies.py     # get_session_manager() DI factory
│   ├── rag/
│   │   ├── schemas.py          # ChatRequest/ChatResponse/ChatSource
│   │   ├── service.py          # RAGService: chat, resume_chat, career_chat, repository_chat,
│   │   │                       # project_chat, and their streaming (chat_stream) counterpart
│   │   └── dependencies.py     # get_rag_service() DI factory
│   ├── orchestration/
│   │   ├── schemas.py          # TailorApplicationRequest/Result
│   │   ├── service.py          # OrchestrationService: the tailored-application pipeline
│   │   └── dependencies.py     # get_orchestration_service() DI factory
│   ├── services/
│   │   └── base_service.py     # BaseService: shared scaffolding (logger) for every service
│   └── schemas/
│       └── common.py           # Shared response schemas (HealthResponse, VersionResponse)
├── tests/                      # pytest suite
├── Dockerfile
├── docker-compose.yml          # Named volumes for uploads/ and data/chroma/ persistence
├── requirements.txt
├── pyproject.toml              # ruff, mypy, pytest configuration
├── .env.example
└── .github/workflows/ci.yml
```

## Installation

Requires **Python 3.12+**.

```bash
git clone <repository-url> career-mcp-server
cd career-mcp-server
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
```

## Environment Variables

All configuration is read from environment variables (or a local `.env` file). See
[`.env.example`](.env.example) for defaults.

| Variable       | Default              | Description                                  |
|----------------|-----------------------|-----------------------------------------------|
| `APP_NAME`     | `career-mcp-server`  | Application name                              |
| `APP_VERSION`  | `1.0.0`               | Application version                           |
| `HOST`         | `0.0.0.0`             | Bind host for the HTTP server                 |
| `PORT`         | `8000`                | Bind port for the HTTP server                 |
| `LOG_LEVEL`    | `INFO`                | Minimum log level (`DEBUG`, `INFO`, ...)      |
| `DEBUG`        | `false`               | Enables verbose diagnostics                   |
| `ENVIRONMENT`  | `development`         | `development` / `staging` / `production` / `test` |
| `CORS_ORIGINS` | `*`                   | Comma-separated list of allowed CORS origins  |
| `GITHUB_TOKEN` | *(none)*              | GitHub Personal Access Token (see [Authentication](#github-authentication)) |
| `GITHUB_API`   | `https://api.github.com` | Base URL for the GitHub REST API           |
| `REQUEST_TIMEOUT` | `30`               | Outbound HTTP request timeout, in seconds     |
| `CACHE_TTL`    | `300`                 | TTL, in seconds, for cached GitHub data (`0` effectively disables caching) |
| `OPENAI_API_KEY` | *(none)*            | OpenAI API key (required for AI-powered resume/career features) |
| `LLM_PROVIDER` | `openai`              | Which `AIProvider` implementation backs AI features |
| `LLM_MODEL`    | `gpt-5`               | Model name requested from the configured provider |
| `MAX_TOKENS`   | `4000`                | Maximum tokens generated per AI completion    |
| `TEMPERATURE`  | `0.3`                 | Sampling temperature for AI completions       |
| `UPLOAD_DIRECTORY` | `uploads`          | Local directory where uploaded resumes are stored |
| `MAX_UPLOAD_SIZE_MB` | `10`             | Maximum allowed resume upload size, in megabytes |
| `ANTHROPIC_API_KEY` | *(none)*         | Anthropic API key (required when `LLM_PROVIDER=anthropic`) |
| `GEMINI_API_KEY` | *(none)*            | Google Gemini API key (required when `LLM_PROVIDER=gemini`) |
| `OPENROUTER_API_KEY` | *(none)*        | OpenRouter API key (required when `LLM_PROVIDER=openrouter`) |
| `AZURE_OPENAI_ENDPOINT` | *(none)*     | Azure OpenAI resource endpoint URL (required when `LLM_PROVIDER=azure`) |
| `AZURE_OPENAI_KEY` | *(none)*          | Azure OpenAI API key (required when `LLM_PROVIDER=azure`) |
| `AZURE_OPENAI_API_VERSION` | `2024-08-01-preview` | Azure OpenAI REST API version |
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Base URL of a running Ollama server (used by `LLM_PROVIDER=ollama` and `EMBEDDING_PROVIDER=ollama`) |
| `EMBEDDING_PROVIDER` | `openai`        | Which provider generates vector embeddings: `openai` / `ollama` / `sentence_transformers` |
| `VECTOR_DB`    | `chroma`              | Which vector database backs semantic search (`chroma` only, for now) |
| `CHROMA_PATH`  | `data/chroma`         | Local filesystem path for the ChromaDB persistent store |
| `SESSION_TTL`  | `3600`                | Conversation session time-to-live, in seconds, before expiration |
| `MAX_CONTEXT_CHUNKS` | `10`             | Maximum number of retrieved chunks included as RAG context per chat turn |

## Running Locally

```bash
uvicorn app.main:app --reload
```

- App: <http://localhost:8000>
- Swagger UI: <http://localhost:8000/docs>
- ReDoc: <http://localhost:8000/redoc>
- OpenAPI schema: <http://localhost:8000/openapi.json>
- Health check: <http://localhost:8000/health>
- Version: <http://localhost:8000/version>

<!--
Screenshots: once this repository has a public URL, add a Swagger UI (/docs) screenshot here,
e.g.:
![Swagger UI](docs/images/swagger-ui.png)
-->

## Docker

```bash
docker compose up --build
```

This builds a non-root, slim production image and starts the API on the port configured by
`PORT` (default `8000`). Two named volumes persist across restarts: `uploads` (stored resume
files) and `chroma_data` (the ChromaDB persistent vector store, mounted at `/app/data/chroma`).

## Running the MCP Server

The MCP server is built in `app/mcp/server.py` and initialized automatically when `app.main` is
imported (e.g. on application startup, it's attached to `app.state.mcp_server`). It registers 52
tools, 7 resources, and 8 prompts in total:

- `ping` — the Phase 1 demonstration tool, proving MCP wiring works end-to-end.
- 22 GitHub tools from `app/mcp/tools/github_tools.py`.
- 13 resume tools + 4 career tools from `app/mcp/tools/resume_tools.py` and `career_tools.py`.
- 4 RAG chat tools + 4 knowledge-base tools + 3 session tools + 1 orchestration tool from
  `app/mcp/tools/{rag,knowledge,session,orchestration}_tools.py` (see
  [Available MCP Tools](#available-mcp-tools-2) below).
- 7 **MCP Resources** (`app/mcp/resources.py`) and 8 **MCP Prompts** (`app/mcp/prompts.py`) — two
  MCP primitives distinct from tools; see [MCP Resources](#mcp-resources) and
  [MCP Prompts](#mcp-prompts) below.

Each integration owns a `register(registry)` function in its own `app/mcp/tools/*` module (or
`register(server)` for resources/prompts, since those attach directly to the server rather than
the shared `ToolRegistry`); `app/mcp/server.py` just calls each of them once before building the
server, so adding a future integration never requires touching existing modules.

MCP tool arguments must be JSON-safe, so file uploads (`upload_resume`, `replace_resume`) take the
PDF's bytes as a base64-encoded string instead of a multipart upload. Streaming isn't exposed as an
MCP tool (tool results are single structured values, not token streams) — use `POST /chat/stream`
for a streaming experience instead.

## GitHub Integration

### Authentication

Generate a [GitHub Personal Access Token](https://github.com/settings/tokens) (a fine-grained
token with read access to the repositories you want to query is sufficient) and set it in `.env`:

```bash
GITHUB_TOKEN=ghp_your_token_here
```

The token is sent as a `Bearer` header on every request and is **never logged**. Without a token,
public endpoints (public repos, search, unauthenticated rate limit) still work — GitHub just
applies its much lower unauthenticated rate limit (60 requests/hour) and endpoints that require a
user context, like `/github/profile`, return a structured `401 AuthenticationException`.

### Available REST Endpoints

All endpoints are under `/github` and return typed JSON (never raw GitHub payloads):

| Method | Path | Description |
|---|---|---|
| GET | `/github/profile` | The authenticated user |
| GET | `/github/pinned` | The authenticated user's pinned repositories (GraphQL) |
| GET | `/github/repos` | List the authenticated user's repositories |
| GET | `/github/search?q=` | Search public repositories |
| GET | `/github/rate-limit` | Current GitHub API rate limit status |
| GET | `/github/repos/{owner}/{repo}` | Repository details |
| GET | `/github/repos/{owner}/{repo}/stats` | Aggregated statistics |
| GET | `/github/repos/{owner}/{repo}/languages` | Language breakdown |
| GET | `/github/repos/{owner}/{repo}/contributors` | Top contributors |
| GET | `/github/repos/{owner}/{repo}/commits` | Latest commits |
| GET | `/github/repos/{owner}/{repo}/release` | Latest release |
| GET | `/github/repos/{owner}/{repo}/releases` | All releases |
| GET | `/github/repos/{owner}/{repo}/readme` | Decoded README |
| GET | `/github/repos/{owner}/{repo}/topics` | Repository topics |
| GET | `/github/repos/{owner}/{repo}/license` | Repository license |
| GET | `/github/repos/{owner}/{repo}/branches` | Branches |
| GET | `/github/repos/{owner}/{repo}/issues` | Issues (excludes pull requests) |
| GET | `/github/repos/{owner}/{repo}/pulls` | Pull requests |
| GET | `/github/repos/{owner}/{repo}/workflows` | GitHub Actions workflows |
| GET | `/github/repos/{owner}/{repo}/stars` | Star count |
| GET | `/github/repos/{owner}/{repo}/forks` | Fork count |
| GET | `/github/repos/{owner}/{repo}/watchers` | Watcher count |
| DELETE | `/github/cache` | Manually invalidate the GitHub integration cache |

Example:

```bash
curl http://localhost:8000/github/repos/octocat/Hello-World/stats
```

### Available MCP Tools

The same functionality, one tool per REST endpoint above: `github_profile`,
`pinned_repositories`, `list_repositories`, `search_repositories`, `rate_limit`,
`repository_details`, `repository_statistics`, `repository_languages`,
`repository_contributors`, `latest_commits`, `latest_release`, `repository_releases`,
`repository_readme`, `repository_topics`, `repository_license`, `repository_branches`,
`repository_issues`, `repository_pull_requests`, `repository_workflows`, `repository_stars`,
`repository_forks`, `repository_watchers`.

Each tool's input/output schema is generated by the MCP SDK directly from its Python type hints
and docstring — there is no separate schema to keep in sync.

### Caching

`GitHubService` caches five read-heavy, slow-changing calls in an in-memory TTL cache (`CACHE_TTL`
seconds, default 300): the authenticated profile, the repository list, repository statistics,
language breakdowns, and releases. Everything else (commits, issues, PRs, branches, search, rate
limit, etc.) always hits GitHub directly. Call `DELETE /github/cache` to clear it manually.

### Rate Limits

GitHub enforces 5,000 requests/hour for authenticated requests and 60/hour unauthenticated (10/min
for search). `GET /github/rate-limit` reports current usage. The client logs remaining quota on
every response and raises a structured `RateLimitException` (HTTP 429) the moment GitHub signals
either the primary or secondary rate limit has been hit — transient 5xx errors and network
failures are retried up to 3 times with exponential backoff before that.

## Resume & AI Career Intelligence

Phase 3 adds resume upload/management, AI-powered resume analysis and scoring, resume-to-job
matching, and AI-generated rewrites, cover letters, and interview prep. All of it sits behind the
same provider-agnostic `AIProvider` abstraction — no business logic imports the OpenAI SDK
directly.

### AI Provider Architecture

```
ResumeService / CareerService
        │  depends only on the abstract interface
        ▼
   AIProvider (app/ai/provider.py)
   ├── complete(prompt, system=..., temperature=..., max_tokens=...) -> str      [abstract]
   ├── stream(...) -> AsyncIterator[str]                                        [abstract]
   └── complete_json(...) -> dict                    [concrete, built on complete()]
        │
        ▼
   BaseAIProvider (app/ai/base_provider.py)   — settings storage, default resolution
        │
        ▼
   OpenAIProvider / AzureProvider / OpenRouterProvider / AnthropicProvider /
   GeminiProvider / OllamaProvider
```

`app/ai/factory.py` exposes `get_ai_provider()`, a cached DI factory keyed by `LLM_PROVIDER`. As of
Phase 4, six providers are implemented — see
[Supported Providers](#supported-providers) in the AI Platform section below. Adding a future
provider means writing one new class implementing `complete()`/`stream()` and registering it in the
factory's provider map — nothing in `ResumeService`, `CareerService`, the REST routes, or the MCP
tools changes.

### Prompt Library

Every prompt used anywhere in the app lives in `app/ai/prompts.py` as a small function returning
plain text — nothing is hardcoded inline in a service. The module has no dependency on resume or
job-description models; callers pass already-serialized text (`model.model_dump_json()`), which
keeps `app/ai` reusable for any future feature, not just resumes.

### Graceful Degradation

The AI provider is injected into `ResumeService`/`CareerService` as a **factory callable**, not a
pre-built instance. That means:

- The app starts successfully with no `OPENAI_API_KEY` set.
- `GET /resume`, `GET /resume/{id}`, `DELETE /resume/{id}` work with no AI configured at all.
- `POST /resume/upload` still works with no AI configured — parsing degrades to deterministic,
  regex-based contact extraction (name/email/phone) with every AI-only field (skills, education,
  experience, ...) left as an empty default, rather than failing the upload.
- Everything genuinely AI-dependent (analyze, score, match, rewrite, cover letters, interview
  questions, career advice, ...) fails with a clean `502 ExternalServiceException` — `"OPENAI_API_KEY
  is not configured; AI-powered features are unavailable"` — the moment it's actually called, not
  at startup.

### Available REST Endpoints

**Resume** (`/resume`) — returns typed JSON, never raw AI text:

| Method | Path | Description |
|---|---|---|
| POST | `/resume/upload` | Upload a PDF resume (multipart), parse, and store it |
| GET | `/resume` | List stored resumes' metadata |
| GET | `/resume/{resume_id}` | Get a stored resume's metadata + parsed content |
| PUT | `/resume/{resume_id}` | Replace a stored resume's file, re-parsing it |
| DELETE | `/resume/{resume_id}` | Delete a stored resume |
| POST | `/resume/analyze` | Full multi-part AI analysis (score, gaps, summaries, strengths/weaknesses) |
| POST | `/resume/score` | Focused resume quality / ATS score |
| POST | `/resume/match` | Compare a resume against a job description |
| POST | `/resume/rewrite` | AI-rewrite the full resume, summary, or experience section |
| POST | `/resume/cover-letter` | Generate a tailored cover letter |
| POST | `/resume/interview` | Generate likely interview questions |
| POST | `/resume/project-descriptions` | Turn raw project notes into a polished description |
| POST | `/resume/achievements` | Turn raw notes into quantified achievement bullets |

**Career** (`/career`) — general career intelligence, not necessarily tied to a stored resume:

| Method | Path | Description |
|---|---|---|
| POST | `/career/advice` | AI-powered career advice for a free-form question |
| POST | `/career/professional-summary` | Standalone summary from a stored resume or raw text |
| POST | `/career/rewrite-bio` | AI-rewrite a short personal bio |
| POST | `/career/parse-job-description` | Extract structured fields from raw job posting text |

### Example Requests & Responses

Upload a resume:

```bash
curl -X POST http://localhost:8000/resume/upload \
  -F "file=@resume.pdf;type=application/pdf"
```

```json
{
  "metadata": {
    "id": "437afbe1254745e89c24197157a92a0c",
    "original_filename": "resume.pdf",
    "content_type": "application/pdf",
    "size_bytes": 48213,
    "uploaded_at": "2026-01-15T10:30:00Z",
    "updated_at": "2026-01-15T10:30:00Z"
  },
  "parsed": {
    "contact": {"name": "Jane Doe", "email": "jane@example.com", "phone": "+1-555-0100", "location": "Remote"},
    "summary": "Backend engineer with 6 years building distributed systems...",
    "skills": ["Python", "FastAPI", "PostgreSQL"],
    "education": ["..."], "experience": ["..."], "projects": ["..."],
    "certifications": [], "languages": [], "publications": [],
    "raw_text": "..."
  }
}
```

Match a resume against a job description:

```bash
curl -X POST http://localhost:8000/resume/match \
  -H "Content-Type: application/json" \
  -d '{"resume_id": "437afbe1254745e89c24197157a92a0c", "job_description": "We need a backend engineer with FastAPI and PostgreSQL experience..."}'
```

```json
{
  "overall_score": 88,
  "skill_match_score": 90,
  "experience_match_score": 85,
  "keyword_coverage_score": 80,
  "missing_skills": ["Kubernetes"],
  "recommendations": ["Add a metric quantifying the API's scale"],
  "ats_compatibility_score": 92
}
```

### Available MCP Tools

**Resume** (13): `upload_resume`, `list_resumes`, `get_resume`, `replace_resume`,
`delete_resume`, `analyze_resume`, `resume_score`, `match_resume`, `rewrite_resume`,
`generate_cover_letter`, `interview_questions`, `project_descriptions`, `achievement_generator`.

**Career** (4): `career_advice`, `professional_summary`, `rewrite_bio`, `parse_job_description`.

Each tool's input/output schema is generated by the MCP SDK directly from its Python type hints
and docstring. `upload_resume` and `replace_resume` take `content_base64` (a base64-encoded PDF)
instead of a file upload, since MCP tool arguments must be JSON-safe.

### Security

- **File type validation** — only `application/pdf` is accepted; anything else is rejected with a
  structured `415 UnsupportedMediaTypeException` before any file processing happens.
- **PDF signature check** — uploaded bytes must start with the `%PDF-` magic bytes, rejecting
  mislabeled files even if the declared content type says PDF.
- **Size limits** — uploads over `MAX_UPLOAD_SIZE_MB` are rejected with `413 PayloadTooLargeException`.
- **Path traversal prevention** — storage is always keyed by a server-generated UUID
  (`{resume_id}.pdf`), never by the user-supplied filename, so path traversal via a crafted
  filename is structurally impossible regardless of storage backend. The original filename is
  still sanitized (directory components stripped, unsafe characters rejected) before being stored
  as display metadata.
- **No public file exposure** — there is no static file route; the only way to retrieve a resume's
  content is through the authenticated-by-deployment `GET /resume/{resume_id}` API, which returns
  parsed data, not the raw file.

## AI Platform, RAG, Memory & Advanced MCP

Phase 4 turns the single-provider AI abstraction from Phase 3 into a full platform: six
interchangeable LLM providers, a pluggable embeddings + vector store layer, a knowledge base built
from indexed resumes and GitHub repositories, retrieval-augmented chat with conversation memory and
streaming, a multi-step orchestration pipeline, and the MCP **Resources** and **Prompts**
primitives alongside tools.

### Supported Providers

| Provider | `LLM_PROVIDER` value | Requires |
|---|---|---|
| OpenAI | `openai` | `OPENAI_API_KEY` |
| Anthropic (Claude) | `anthropic` | `ANTHROPIC_API_KEY` |
| Google Gemini | `gemini` | `GEMINI_API_KEY` |
| Azure OpenAI | `azure` | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| Ollama (local models) | `ollama` | `OLLAMA_BASE_URL` running (no API key) |

All six implement the same `AIProvider` interface (`complete()`, `complete_json()`, `stream()`,
`aclose()`); `OpenAIProvider`, `AzureProvider`, and `OpenRouterProvider` share their wire-format
logic via `ChatCompletionsMixin` since all three speak the OpenAI Chat Completions API. Selecting a
provider is one environment variable (`LLM_PROVIDER`) — nothing in `ResumeService`, `CareerService`,
`RAGService`, or the REST/MCP layers changes.

Embeddings follow the identical pattern via `EmbeddingProvider` (`app/ai/embeddings/`):

| Provider | `EMBEDDING_PROVIDER` value | Requires |
|---|---|---|
| OpenAI | `openai` | `OPENAI_API_KEY` |
| Ollama | `ollama` | `OLLAMA_BASE_URL` running |
| Sentence Transformers (local) | `sentence_transformers` | `pip install sentence-transformers` (optional, ~2GB with torch — not installed by default) |

### Vector Database & Document Chunking

Semantic search is backed by a `VectorStore` abstraction (`app/vectorstore/store.py`) with a
ChromaDB implementation (`ChromaVectorStore`, `VECTOR_DB=chroma`, the only backend implemented so
far — Pinecone/Qdrant/Weaviate/FAISS are future work behind the same interface). ChromaDB's
synchronous Python client is run through `asyncio.to_thread()` to stay consistent with the app's
async-first design; its persistent store lives at `CHROMA_PATH` (default `data/chroma`, gitignored
and given its own Docker volume — see [Docker](#docker)).

Documents are split for indexing by `app/knowledge/chunking.py`: a recursive splitter tries
paragraph breaks, then lines, then sentences, then words (falling back to a hard character cut only
for a single unbroken run longer than `chunk_size`), then greedily re-merges adjacent pieces up to
`chunk_size` characters with `chunk_overlap` characters carried from the end of one chunk into the
start of the next, so retrieval doesn't lose context at chunk boundaries. Every chunk carries
metadata (`source_type`, `source_id`, `section`, ...) so search results can be filtered and traced
back to their origin.

### Knowledge Base

`KnowledgeService` (`app/knowledge/service.py`) indexes two kinds of source into two ChromaDB
collections:

- **Resume** (`resumes` collection) — `app/knowledge/resume_indexer.py` builds section-tagged
  chunks (summary, skills, education, experience, project, certification) from a stored resume.
- **GitHub** (`github` collection) — `app/knowledge/github_indexer.py` builds chunks from a
  repository's README, languages, topics, releases, and recent commits.

Search targets (`resume`, `projects`, `skills`, `repositories`, `documents`) map to one or both
collections, optionally filtered by chunk metadata (e.g. `projects` searches the `resumes`
collection filtered to `section=project`); `documents` searches both collections. `reindex_all()`
clears both collections and re-indexes every stored resume (GitHub repositories aren't
automatically tracked, so they're re-indexed on demand via `POST /knowledge/index`).

### Retrieval-Augmented Generation & Conversation Memory

`RAGService` (`app/rag/service.py`) is the single implementation behind every chat variant:
`chat()` takes a `mode` (`general` / `resume` / `project` / `career` / `repository`) that selects
which knowledge-base target to search for context; `resume_chat()`, `project_chat()`,
`career_chat()`, and `repository_chat()` are thin wrappers with the mode pre-selected. Each turn:
sanitizes the message (`app/ai/sanitize.py`), resolves or creates a conversation session, retrieves
relevant chunks via `KnowledgeService.semantic_search()`, builds a prompt from the retrieved context
plus prior conversation history, calls the configured `AIProvider`, and appends both the user
message and the reply to session history.

Conversation memory lives in `app/memory/`: `SessionManager` handles create/resume/delete and
expiration (`SESSION_TTL` seconds of inactivity); `ConversationStore` is an abstract interface with
an in-memory implementation; `context_builder.py` renders a session's summary + recent messages
into the prompt.

### Streaming

`POST /chat/stream` returns a `text/event-stream` Server-Sent Events response: the first event is
`event: session` carrying the (possibly newly created) session ID, so a client can learn it before
any reply text arrives, followed by `event: message` chunks as the LLM streams its reply, ending
with `event: done`. Streaming isn't exposed as an MCP tool (see
[Running the MCP Server](#running-the-mcp-server)).

### MCP Resources

Resources are a distinct MCP primitive from tools: clients discover and read them by URI rather
than invoking them with arguments. Registered in `app/mcp/resources.py`:

| URI | Description |
|---|---|
| `resume://list` | Metadata for every stored resume |
| `resume://{resume_id}` | A specific stored resume's metadata and full parsed content (templated) |
| `portfolio://summary` | Every stored resume plus pinned GitHub repositories |
| `github://repositories` | The authenticated user's GitHub repositories |
| `career://history` | Education + experience aggregated across stored resumes |
| `skills://all` | Unique skills aggregated across all stored resumes |
| `projects://all` | Projects aggregated across all stored resumes |
| `documents://index` | What's currently indexed in the knowledge base |

### MCP Prompts

Prompts are reusable, discoverable templates an MCP client fetches and sends to *its own* LLM (this
server doesn't call an AI provider on the client's behalf for these). Registered in
`app/mcp/prompts.py`, with wording centralized in `app/ai/prompts.py`: `resume_review`,
`interview_preparation`, `career_coach`, `github_reviewer`, `project_explainer`,
`portfolio_reviewer`, `cover_letter_generator`, `prompt_engineer`.

### Tool Orchestration

`OrchestrationService.tailor_application()` (`app/orchestration/service.py`) chains existing
services into one pipeline rather than duplicating logic: fetch a stored resume for its skills →
parse the job description (`CareerService.parse_job_description()`) → retrieve relevant indexed
projects and GitHub context (`KnowledgeService`) → generate a tailored resume, cover letter, and
interview questions (`ResumeService`) — returned as one combined, structured result.

### Semantic Search

`KnowledgeService.semantic_search()` and its named wrappers (`search_resume()`,
`search_projects()`, `search_skills()`, `search_repositories()`, `search_documents()`) embed the
query with the configured `EmbeddingProvider`, query the relevant ChromaDB collection(s), and
return results ranked by a cosine-similarity approximation (`1 - distance`, clamped to `[0, 1]`),
optionally filtered by a minimum `threshold`.

### Observability

`app/core/metrics.py` provides dependency-free, thread-safe counters and histograms (deliberately
not a Prometheus/OpenTelemetry integration — swapping one in later means replacing this module's
internals without touching any call site): HTTP request counts/duration, LLM request duration and
token usage, cache hits/misses, vector search duration, and session created/deleted counts. Exposed
via `GET /metrics`.

### Security

- **Prompt-injection mitigation** — `app/ai/sanitize.py` strips control characters and caps input
  length (`sanitize_user_input()`), and can fence untrusted retrieved content with explicit
  "not instructions" delimiters (`fence_untrusted_content()`). Documented as practical, not
  foolproof, mitigations.
- **No internal prompt exposure** — prompt templates are never returned to clients; only their
  rendered output (chat replies) or, for MCP Prompts, the template itself when a client explicitly
  requests that named prompt (by design, since that's the MCP Prompts contract).
- **API keys never logged** — every provider constructor validates key presence and raises a
  structured `ExternalServiceException` without ever logging the key value.

### Available REST Endpoints

| Method | Path | Description |
|---|---|---|
| POST | `/chat` | Retrieval-augmented chat, one complete response |
| POST | `/chat/stream` | Same, streamed via Server-Sent Events |
| POST | `/search` | Semantic search across the knowledge base (alias for `/knowledge/search`) |
| POST | `/sessions` | Create a new conversation session |
| GET | `/sessions` | List active (non-expired) conversation sessions |
| GET | `/sessions/{session_id}` | Get a session's full message history |
| DELETE | `/sessions/{session_id}` | Delete a conversation session |
| POST | `/knowledge/index` | Index a resume or GitHub repository into the knowledge base |
| GET | `/knowledge/status` | Document counts per knowledge-base collection |
| POST | `/knowledge/reindex` | Clear and re-index every stored resume |
| POST | `/knowledge/search` | Semantic search across the knowledge base |
| GET | `/metrics` | In-process observability metrics snapshot |
| POST | `/orchestration/tailor-application` | Run the full resume-to-tailored-application pipeline |

### Available MCP Tools

**RAG chat** (4): `chat`, `resume_chat`, `career_chat`, `repository_chat`.

**Knowledge base** (4): `semantic_search`, `knowledge_status`, `reindex_documents`,
`generate_embeddings`.

**Sessions** (3): `create_session`, `delete_session`, `conversation_history`.

**Orchestration** (1): `tailor_application`.

### Example Workflow

Index a resume, then ask a scoped question grounded in it:

```bash
curl -X POST http://localhost:8000/knowledge/index \
  -H "Content-Type: application/json" \
  -d '{"source_type": "resume", "resume_id": "437afbe1254745e89c24197157a92a0c"}'

curl -X POST http://localhost:8000/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "What are my strongest technical skills?", "mode": "resume"}'
```

```json
{
  "session_id": "8b200fba8fa24aab923ae7c025afe05e",
  "reply": "Based on your resume, your strongest skills are Python, FastAPI, and PostgreSQL...",
  "sources": [{"id": "...", "text": "...", "score": 0.87}]
}
```

Run the full tailored-application pipeline in one call:

```bash
curl -X POST http://localhost:8000/orchestration/tailor-application \
  -H "Content-Type: application/json" \
  -d '{"resume_id": "437afbe1254745e89c24197157a92a0c", "job_description": "We need a backend engineer with FastAPI and PostgreSQL experience..."}'
```

## Testing

```bash
pytest --cov=app --cov-report=term-missing
```

Tests cover:

- The `/health` and `/version` endpoints
- Configuration loading, validation, and caching
- The MCP server's initialization and the `ping` tool
- Application startup/shutdown lifecycle
- The GitHub HTTP client: auth headers, 401/403/404/429/5xx mapping, retry-with-backoff,
  pagination — all against `httpx.MockTransport`, no live network calls
- `GitHubService`: every method, JSON-to-model mapping, caching behavior, input validation
- Every `/github/*` REST endpoint, including error-envelope translation, via dependency overrides
- Every GitHub MCP tool: registration, delegation, and typed-exception propagation
- The AI provider abstraction: `complete_json` JSON/code-fence parsing, `OpenAIProvider` (with the
  OpenAI SDK client mocked), and the `get_ai_provider()` factory's provider selection/error paths
- Resume upload validation, local storage (save/get/list/delete/replace), PDF text extraction
  against a real (hand-built, dependency-free) PDF, and AI-powered field extraction with graceful
  degradation when the AI provider is unavailable or fails
- `ResumeService` and `CareerService`: every method, against in-memory storage and AI provider
  fakes — no filesystem I/O beyond a `tmp_path`, no real AI calls
- Every `/resume/*` and `/career/*` REST endpoint and every resume/career MCP tool: registration,
  delegation, file upload handling, and error-envelope translation
- All 6 LLM providers (Anthropic, Gemini, Ollama, Azure, OpenRouter, plus the existing OpenAI
  provider), each with its SDK/HTTP client fully mocked, and the prompt-injection sanitizer
- All 3 embedding providers, and `ChromaVectorStore` exercised against a real ChromaDB instance
  backed by `tmp_path` (deliberately not mocked — this is what surfaced a real empty-metadata
  rejection bug in the installed ChromaDB version)
- Document chunking: separator fallback, overlap carry-over, and the oversized-chunk edge case
  found when `chunk_overlap` is large relative to `chunk_size`
- `KnowledgeService`: indexing, status, reindexing, and every semantic-search target, against
  in-memory vector-store and embedding-provider fakes typed against the real Phase 2/3 models
- Conversation memory: the in-memory store, `SessionManager` (create/resume/expire/append/history),
  and the context builder
- `RAGService` and `OrchestrationService`: every chat mode, streaming, session handling, and the
  full tailored-application pipeline, against fakes — no real AI or vector-store calls
- `MetricsRegistry`: counters, histograms, the `timer()` context manager, and snapshot ordering
- Every new `/chat`, `/sessions`, `/knowledge/*`, `/search`, `/metrics`, and
  `/orchestration/*` REST endpoint via dependency overrides
- Every new RAG/knowledge/session/orchestration MCP tool: registration, delegation, and
  typed-exception propagation
- Every MCP Resource (list + read, via the live MCP server) and every MCP Prompt (list + argument
  rendering, via the live MCP server)

No test makes a real GitHub API call or a real AI API call — every external client is fully
swappable via dependency injection.

## CI/CD

GitHub Actions ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs on every push and
pull request to `main`:

1. Install dependencies (pip cache enabled)
2. `ruff check .` — lint
3. `mypy app` — type-check (strict mode)
4. `pytest --cov=app --cov-fail-under=90` — test, failing the build if coverage regresses below
   90%; the coverage report (`coverage.xml`) is uploaded as a workflow artifact
5. Build the production Docker image via `docker/build-push-action`, with GitHub Actions layer
   caching (`cache-from`/`cache-to: type=gha`) so unchanged layers aren't rebuilt on every run

The pipeline fails on any lint, type, test, coverage-threshold, or Docker build error.

### Local pre-commit hooks (optional)

```bash
pip install pre-commit
pre-commit install
```

Runs `ruff check --fix` and `mypy app` automatically before each commit, plus standard hygiene
checks (trailing whitespace, large files, merge conflict markers). Configured in
[`.pre-commit-config.yaml`](.pre-commit-config.yaml).

## Roadmap

- **Phase 1** — Project foundation: FastAPI app, MCP server bootstrap, configuration, logging,
  middleware, structured error handling, Docker, CI.
- **Phase 2** — GitHub integration: async client, typed models, caching service layer, 22 REST
  endpoints, and 22 matching MCP tools.
- **Phase 3** — AI career intelligence & resume engine: provider-agnostic AI abstraction, PDF
  resume upload/parsing/storage, AI-powered analysis/scoring/matching/rewriting, 17 REST endpoints,
  and 17 matching MCP tools.
- **Phase 4** — AI platform, RAG, memory & advanced MCP: 6 LLM providers, 3 embedding providers, a
  ChromaDB-backed vector store, resume + GitHub knowledge-base indexing, retrieval-augmented chat
  with conversation memory and SSE streaming, a tailored-application orchestration pipeline, MCP
  Resources and Prompts, observability metrics, 13 new REST endpoints, and 12 matching MCP tools.
- **v1.0.0 (this repository)** — the first stable release: Phases 1–4 combined, audited, and
  hardened for production use. See [CHANGELOG.md](CHANGELOG.md) and
  [RELEASE_NOTES_v1.0.0.md](RELEASE_NOTES_v1.0.0.md).
- **Phase 5** — LinkedIn integration.
- **Phase 6** — Further AI-powered career insights and recommendations.

## Known Limitations

- **Conversation memory is in-memory only.** `ConversationStore` (`app/memory/store.py`) has an
  in-memory implementation only, so chat sessions do not survive an application restart. The
  interface is designed to support a persistent backend (Redis, a database) later without
  changing `SessionManager` or any caller.
- **Vector store: ChromaDB only.** `VectorStore` (`app/vectorstore/store.py`) is designed as a
  swappable interface, but Pinecone/Qdrant/Weaviate/FAISS implementations don't exist yet.
- **No built-in authentication.** Neither the REST API nor the MCP server enforces
  authentication/authorization — see [SECURITY.md](SECURITY.md) for deployment guidance.
- **GitHub knowledge-base indexing is manual.** Unlike resumes (which `reindex_all()` re-indexes
  automatically), GitHub repositories must be indexed explicitly via `POST /knowledge/index`
  (or the `semantic_search`-adjacent MCP tools) — they aren't tracked/discovered automatically.
- **Cosine similarity scores are an approximation** (`1 - distance`, clamped to `[0, 1]`), not a
  formally calibrated probability — treat `threshold` filtering as a useful heuristic, not an
  exact guarantee.

## FAQ

**Do I need every LLM provider's API key to run this?**
No. `LLM_PROVIDER` selects exactly one provider; only that provider's key is required. The app
also starts successfully with **no** AI key configured at all — AI-dependent endpoints return a
clean `502 ExternalServiceException` only when actually called (see
[Graceful Degradation](#graceful-degradation)).

**Can I use a fully local setup (no cloud AI calls)?**
Yes — set `LLM_PROVIDER=ollama` and `EMBEDDING_PROVIDER=ollama` (or `sentence_transformers`) with
a local [Ollama](https://ollama.com) server running; no API key or outbound AI traffic is
required. GitHub calls still go to api.github.com unless you don't use the GitHub integration.

**Why MCP *and* REST — why not just one?**
Different clients need different transports: a human or a traditional frontend calls REST; an
LLM-based agent (Claude Desktop, an MCP-aware IDE, etc.) calls MCP tools directly. Building both
on top of identical service classes means neither transport is a second-class citizen or a
maintenance burden.

**Is this production-ready?**
As of `v1.0.0`, yes, within the scope described in [Known Limitations](#known-limitations) above
and [SECURITY.md](SECURITY.md) — it's fully tested, typed, containerized, and has run through a
full production-readiness audit. Evaluate the specific limitations against your own deployment
requirements first.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. In short:

1. Create a feature branch.
2. Make your changes with type hints, docstrings, and tests.
3. Run `ruff check .`, `mypy app`, and `pytest` locally (or install the
   [pre-commit hooks](#local-pre-commit-hooks-optional)).
4. Open a pull request; CI must pass before merge.

This project follows the [Code of Conduct](CODE_OF_CONDUCT.md). Found a security issue? See
[SECURITY.md](SECURITY.md) instead of opening a public issue.

## License

Distributed under the [MIT License](LICENSE).