Skip to main content
Glama
mohitakki

Germany Intelligence MCP

by mohitakki
README.md
<div align="center">

# πŸ‡©πŸ‡ͺ Germany Intelligence MCP

**An evidence-based career intelligence layer for the German software job market, exposed to Claude as an MCP server.**

[![CI](https://github.com/mohitakki/germany-intelligence-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/mohitakki/germany-intelligence-mcp/actions/workflows/ci.yml)
[![Node](https://img.shields.io/badge/node-%3E%3D22.5-339933?logo=node.js&logoColor=white)](.nvmrc)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178c6?logo=typescript&logoColor=white)](tsconfig.json)
[![Tests](https://img.shields.io/badge/tests-106%20passing-success)](tests/)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Status](https://img.shields.io/badge/status-v1.0%20Core%20(not%20production%20ready)-orange)](docs/KNOWN-ISSUES.md)

*Not a job search tool. A decision engine that answers one question every morning:*

> ### β€œWhat should I learn today to maximise my chances of getting a software engineering job in Germany?”

</div>

---

## ⚠️ Read this before using it

**v1.0 Core is infrastructure, not a finished product.** Its own QA pass graded it
**C β€” Needs Another Stabilization Sprint**, and that assessment is published in full:

| | |
|---|---|
| βœ… **Works** | Ingestion, deduplication, storage, trend analysis, full-text search, metrics, health, MCP tool surface |
| ⚠️ **Known-wrong** | German language detection has **50% recall**. Extraction misses hyphenated German compounds in **24%** of postings |
| ❌ **Not implemented** | 4 of 5 recommendation surfaces are stubs. The 5th would tell a C1 English speaker to *learn English* |

πŸ‘‰ **[docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md)** β€” every defect, measured, reproducible, and pinned by a test.

This honesty is deliberate. The project's core promise is that no claim is made
without evidence, and that has to apply to claims about the project itself.

---

## The idea

Most job tools optimise for *finding postings*. This one assumes finding jobs is easy
and **knowing what to learn is hard**. It treats the job market as a dataset and asks
what the evidence actually says.

```
                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                          β”‚   Claude (reasoning layer)   β”‚
                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                          β”‚ MCP / JSON-RPC over stdio
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  L5  MCP TOOL SURFACE       10 coarse tools Β· Zod-validated Β· evidence     β”‚
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    β”‚  L4  ENGINES                trend Β· essentialism Β· gap Β· match Β· learning  β”‚
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    β”‚  L3  KNOWLEDGE STORE        SQLite + FTS5 Β· repositories own all SQL       β”‚
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    β”‚  L2  ANALYSIS PIPELINE      normalise β†’ extract β†’ classify β†’ dedupe        β”‚
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    β”‚  L1  SOURCE ADAPTERS        anti-corruption layer Β· one interface, N APIs  β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

**Dependency rule:** arrows point downward only. An engine never imports a source
adapter; a source adapter never imports the database.

### What "evidence-based" means here

No number is emitted without its denominator. This is enforced by the **type system**
and the **database schema**, not by convention:

```ts
interface Evidence {
  subject: string;         // "ops.docker"
  frequency: number;       // 0.63
  observedIn: number;      // 214   ← the numerator
  totalAnalysed: number;   // 339   ← THE DENOMINATOR
  windowDays: number;      // 14
  sampleJobIds: string[];  // openable, verifiable
  meanConfidence: number;  // how good was the extraction behind this
  lowConfidence: boolean;  // sample too small to act on
}
```

`recommendation.evidence` is `NOT NULL`. `pickTodayFocus()` cannot compile without
threading evidence through. A recommendation you cannot defend is unrepresentable.

---

## Quick start

```bash
git clone https://github.com/mohitakki/germany-intelligence-mcp.git
cd germany-intelligence-mcp

npm ci                                              # no native build, no compiler
cp .env.example .env
cp config/profile.example.json config/profile.json  # then edit your skill levels

npm test          # 106 tests, ~40 against real SQLite
npm run ingest    # live fetch from the German market
npm run health    # is the data trustworthy right now?
```

**No native dependencies.** Storage is `node:sqlite` behind a driver port β€” see
[ADR-006](docs/ADR/006-storage.md) for why that decision and a production bug were
the same decision.

<details>
<summary><b>Wire it into Claude Desktop</b></summary>

```bash
npm run build
```

`claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "germany-intelligence": {
      "command": "node",
      "args": ["--no-warnings", "/absolute/path/to/germany-intelligence-mcp/dist/index.js"],
      "env": {
        "GIM_DATA_DIR": "/absolute/path/to/germany-intelligence-mcp/data",
        "GIM_PROFILE_PATH": "/absolute/path/to/germany-intelligence-mcp/config/profile.json"
      }
    }
  }
}
```

Restart Claude, then ask: *"Run my daily briefing."*
</details>

---

## MCP tools

| Tool | Status | What it does |
|---|:--:|---|
| `health` | βœ… | Database + FTS, providers, last ingest age, extraction quality, jobs indexed. **Call this first if anything looks odd.** |
| `refresh_market` | βœ… | Fetch, normalise, dedupe, store. Returns full run metrics. |
| `get_market_trends` | βœ… | Technology demand with counts, frequencies, confidence and sample job ids. |
| `get_skill_gap` | βœ… | Your profile vs the market, bucketed and ranked, each item evidenced. |
| `search_jobs` | βœ… | BM25 full-text + filters. One row per deduplicated vacancy. |
| `list_sources` | βœ… | Providers, config state, legal status, compliance notes. |
| `mark_progress` | βœ… | Records learning so a skill stops being recommended. |
| `get_daily_briefing` | ◐ | Market summary + trends + gaps. Recommendation sections are empty. |
| `get_today_focus` | ❌ | Throws β€” Phase 6. |
| `generate_interview_questions` Β· `analyse_resume` | ❌ | Return `[]` β€” Phase 7/8. |

Ten coarse tools, not thirty. Every description is loaded into Claude's context on
**every turn**, so tool sprawl directly degrades reasoning quality.

---

## Data sources

Only sources that permit programmatic access are shipped.

| Source | Status | Coverage |
|---|:--:|---|
| **Bundesagentur für Arbeit** | ⚠️ implemented, never network-tested | Germany's largest job database (~1M postings). Official federal API, public client id, no registration. |
| **Arbeitnow** | βœ… verified end-to-end | Public board API, no key. Berlin/Munich tech, English-language β€” the visa-sponsoring startup segment. |
| ATS boards (Greenhouse, Lever, Ashby, Personio, Recruitee) | πŸ”² Phase 3 | Canonical postings from the companies you actually want. Highest-value next build. |
| `src/sources/custom/` | πŸ”² slot | Your own adapters, disabled by default, `complianceNote` required. |

<details>
<summary><b>Why no LinkedIn / StepStone / Indeed / Xing</b></summary>

None offer an open job-search API. All four prohibit automated access in their Terms
of Service and enforce it technically. No adapter ships for them and none should be
added β€” an IP ban is the mild outcome; a legal notice while applying for a German
work visa is the bad one.

`src/sources/custom/` exists for sources you have a legitimate route to: company
career feeds, boards with a documented API, partner feeds, manual CSV import.

See [ADR-001](docs/ADR/001-provider.md).
</details>

---

## How a recommendation earns its place

Five gates, all of which must pass:

1. **Sample floor** β€” β‰₯ 20 deduplicated jobs analysed
2. **Evidence floor** β€” the skill appears in β‰₯ 5 postings
3. **Relevance floor** β€” β‰₯ 10% of jobs request it
4. **Deficit** β€” your self-assessed level is below 4/5
5. **Cooldown** β€” not recommended in the last 21 days

Survivors are ranked:

```
priority = marketFrequency Γ— skillDeficit Γ— momentum
```

Multiplicative, not additive β€” so a skill you already have scores β‰ˆ 0 no matter how
in-demand it is. That is the mechanism that stops the system telling you to learn
React for the fortieth time.

---

## The engineering story

This repo went through **four adversarial cycles** after "done". Each one is documented,
because the findings are more interesting than the code.

### πŸ”΄ The write path was dead and the run said `ok`

`JobRepository.upsert` ended with `INSERT INTO job_fts (...) ON CONFLICT DO NOTHING`.
SQLite rejects UPSERT against a virtual table. Every insert threw, `runIngest` caught
it per-job, and the run reported success having stored **nothing**.

The schema had been "verified" by running a hand-written query against hand-inserted
rows β€” never the repository's own statement. **A test that doesn't cross the boundary
tests nothing.** β†’ [ADR-006](docs/ADR/006-storage.md)

### πŸ”΄ A Berlin train line was inflating AWS demand

Eight of twelve ordinary German sentences produced phantom skill demand:

| Sentence (verbatim from real postings) | Phantom skill |
|---|---|
| "no **less** than three years of experience" | CSS/Less |
| "the position starts in **spring** 2027" | Spring Boot |
| "gut erreichbar mit der **S3** und der U2" | AWS ← *an S-Bahn line* |
| "you **react** quickly to incidents" | React |

The defect wasn't the aliases β€” it was the **default**. A token was assumed technical
unless something stopped it. Now ambiguous aliases are assumed non-technical unless a
requirement marker or a confirmed technology list proves otherwise.
β†’ [ADR-003](docs/ADR/003-taxonomy.md)

### πŸ”΄ The dedup threshold was picked, not measured

Version one used Hamming ≀ 3 because it "felt safely conservative." It matched nothing
but byte-identical text.

```
 0 bits  identical                    11 bits  ~15% extra boilerplate
 5 bits  hyphenation edit             13 bits  bullets reordered
 9 bits  one extra sentence           31 bits  a completely different job
```

Real variants cluster at 5–13; unrelated documents sit past 30. **12 sits in a wide,
empty gap.** β†’ [ADR-002](docs/ADR/002-dedup.md)

### πŸ”΄ The extractor was accurate; the data was wrong

Ground truth over 111 real postings: **96.6% recall, 1 false positive in 59 mentions.**
The extractor is fine. The corpus genuinely contains almost no React/TypeScript work β€”
TypeScript appears in 3 of 111 postings.

Diagnosing this as a *source selection* problem rather than an extraction problem is
the difference between fixing it and tuning the wrong knob.

---

## Documentation

| Document | What it is |
|---|---|
| **[KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md)** | Every measured defect with a reproduction. **Read before trusting output.** |
| **[RUNBOOK.md](docs/RUNBOOK.md)** | Daily operation, metric interpretation, backup, replay, troubleshooting |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Layers, data flow, dependency rules, trade-offs |
| [ROADMAP.md](ROADMAP.md) | Build order with reasoning, ~35 h to complete |
| [docs/ADR/](docs/ADR/README.md) | 7 decision records β€” **why**, with rejected alternatives |

<details>
<summary><b>Architecture Decision Records</b></summary>

| ADR | Decision |
|---|---|
| [001](docs/ADR/001-provider.md) | Provider architecture and the anti-corruption layer |
| [002](docs/ADR/002-dedup.md) | SimHash deduplication, blocking, measured threshold |
| [003](docs/ADR/003-taxonomy.md) | Curated taxonomy + context-window gating |
| [004](docs/ADR/004-prompt-boundary.md) | Untrusted-content trust boundary |
| [005](docs/ADR/005-llm.md) | LLM as additive enrichment only, never authoritative |
| [006](docs/ADR/006-storage.md) | SQLite via `node:sqlite` behind a driver port |
| [007](docs/ADR/007-evidence.md) | Evidence as a required type |

</details>

---

## Testing

**106 tests.** ~40 cross into real SQLite. Three suites, three jobs:

```bash
npm test                                   # everything
npx tsx --test tests/regressions.test.ts   # one block per shipped bug
npx tsx --test tests/corpus.test.ts        # 111 real postings, pinned baselines
```

- **`extraction.test.ts`** β€” pure logic: German gender markers, company suffixes, SimHash, the essentialism filter
- **`regressions.test.ts`** β€” one `describe` per critical bug, with the original defect in the comment so nobody "fixes" the test later
- **`ingest.test.ts`** β€” pipeline β†’ repository β†’ SQLite, error isolation, migration idempotency
- **`corpus.test.ts`** β€” 111 real German postings. Pins the measured baselines **and the known defects**. When you fix one, its test fails β€” that failure is the proof.

The corpus is [committed](tests/fixtures/corpus-de-111.json) so every number in the
docs is reproducible.

---

## Security

- **Prompt injection** β€” job text is attacker-controlled. Nonce-delimited envelope built *before* the LLM path exists; detection counts, never filters ([ADR-004](docs/ADR/004-prompt-boundary.md))
- **Credential redaction** β€” no query string ever reaches a log line, enforced at the boundary and again centrally
- **SQL injection** β€” fully parameterised; verified against `'); DROP TABLE job;--`
- **Adversarial robustness** β€” 27 hostile inputs (500 KB descriptions, malformed HTML, emoji, RTL, null bytes, FTS syntax), **zero crashes**

---

## Contributing

Personal project, but the patterns are reusable. If you fork it:

1. Read [ADR-003](docs/ADR/003-taxonomy.md) before touching the taxonomy β€” several constraints look arbitrary and are load-bearing
2. `npm test` must stay green; the false-positive corpus is the guard rail
3. New decisions get a new ADR β€” [template](docs/ADR/000-template.md). ADRs are immutable once Accepted; supersede, don't edit

---

<div align="center">

**MIT** Β· Built as a systems-engineering exercise in evidence-based recommendation.
Not affiliated with Bundesagentur fΓΌr Arbeit or Arbeitnow.

</div>