context-forge-cli 0.2.5__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/PKG-INFO +107 -1
  2. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/README.md +105 -0
  3. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/cli.py +25 -0
  4. context_forge_cli-0.3.0/contextforge/mcp_server.py +219 -0
  5. context_forge_cli-0.3.0/contextforge/utils/tokens.py +25 -0
  6. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/pyproject.toml +3 -1
  7. context_forge_cli-0.3.0/tests/conftest.py +32 -0
  8. context_forge_cli-0.3.0/tests/test_mcp_server.py +285 -0
  9. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/uv.lock +357 -2
  10. context_forge_cli-0.2.5/contextforge/utils/tokens.py +0 -17
  11. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/.contextforge.toml +0 -0
  12. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  13. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/.gitignore +0 -0
  14. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/.python-version +0 -0
  15. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/AGENTS.md +0 -0
  16. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/PLAN.md +0 -0
  17. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/__init__.py +0 -0
  18. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/__init__.py +0 -0
  19. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/altimate_code.py +0 -0
  20. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/base.py +0 -0
  21. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/claude_code.py +0 -0
  22. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/claude_desktop.py +0 -0
  23. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/codex.py +0 -0
  24. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/gemini.py +0 -0
  25. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/adapters/registry.py +0 -0
  26. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/__init__.py +0 -0
  27. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/analytics.py +0 -0
  28. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/compactor.py +0 -0
  29. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/db.py +0 -0
  30. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/injector.py +0 -0
  31. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/scanner.py +0 -0
  32. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/summarizer.py +0 -0
  33. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/core/token_analyzer.py +0 -0
  34. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/models/__init__.py +0 -0
  35. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/models/config.py +0 -0
  36. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/models/session.py +0 -0
  37. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/__init__.py +0 -0
  38. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/app.py +0 -0
  39. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/styles.tcss +0 -0
  40. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/widgets/__init__.py +0 -0
  41. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/widgets/session_detail.py +0 -0
  42. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/widgets/session_table.py +0 -0
  43. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/widgets/stats_panel.py +0 -0
  44. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/widgets/status_bar.py +0 -0
  45. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/widgets/tokens_panel.py +0 -0
  46. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/tui/widgets/transfer_panel.py +0 -0
  47. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/utils/__init__.py +0 -0
  48. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/contextforge/utils/display.py +0 -0
  49. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/__init__.py +0 -0
  50. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/adapters/__init__.py +0 -0
  51. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/adapters/test_claude_code.py +0 -0
  52. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/core/__init__.py +0 -0
  53. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/core/test_compactor.py +0 -0
  54. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/core/test_db.py +0 -0
  55. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/core/test_injector.py +0 -0
  56. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/core/test_summarizer.py +0 -0
  57. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/core/test_token_analyzer.py +0 -0
  58. {context_forge_cli-0.2.5 → context_forge_cli-0.3.0}/tests/fixtures/claude_session_sample.jsonl +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-forge-cli
3
- Version: 0.2.5
3
+ Version: 0.3.0
4
4
  Summary: Session manager and context bridge for agentic CLI tools
5
5
  Project-URL: Homepage, https://github.com/emmver/contextforge
6
6
  Project-URL: Repository, https://github.com/emmver/contextforge
@@ -18,6 +18,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
18
18
  Classifier: Topic :: Utilities
19
19
  Requires-Python: >=3.13
20
20
  Requires-Dist: anthropic>=0.89.0
21
+ Requires-Dist: mcp>=1.9.0
21
22
  Requires-Dist: pydantic>=2.12.5
22
23
  Requires-Dist: rich>=14.3.3
23
24
  Requires-Dist: sqlite-utils>=3.39
@@ -304,6 +305,111 @@ Press `t` on any session to open the transfer panel. Choose:
304
305
  - **Preview** — shows the exact shell command (no side effects)
305
306
  - **Execute** — builds the bundle and launches the target tool
306
307
 
308
+ ## MCP Server
309
+
310
+ ContextForge ships a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes session data and token analytics to LLM agents.
311
+
312
+ ### Setup
313
+
314
+ ```bash
315
+ # Run directly (after installation)
316
+ cf-mcp
317
+
318
+ # From source
319
+ uv run python -m contextforge.mcp_server
320
+ ```
321
+
322
+ Register the server with your agentic tool of choice:
323
+
324
+ #### Claude Code
325
+
326
+ Add to `~/.claude/claude_desktop_config.json` (global) or `.claude/mcp.json` (project):
327
+ ```json
328
+ {
329
+ "mcpServers": {
330
+ "contextforge": {
331
+ "command": "cf-mcp"
332
+ }
333
+ }
334
+ }
335
+ ```
336
+
337
+ #### Cursor
338
+
339
+ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
340
+ ```json
341
+ {
342
+ "mcpServers": {
343
+ "contextforge": {
344
+ "command": "cf-mcp"
345
+ }
346
+ }
347
+ }
348
+ ```
349
+
350
+ Restart Cursor fully after saving.
351
+
352
+ #### Codex CLI
353
+
354
+ Add to `~/.codex/config.toml` (global) or `.codex/config.toml` (project):
355
+ ```toml
356
+ [mcp_servers.contextforge]
357
+ command = "cf-mcp"
358
+ enabled = true
359
+ ```
360
+
361
+ #### Gemini CLI
362
+
363
+ Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
364
+ ```json
365
+ {
366
+ "mcpServers": {
367
+ "contextforge": {
368
+ "command": "cf-mcp"
369
+ }
370
+ }
371
+ }
372
+ ```
373
+
374
+ #### Antigravity
375
+
376
+ Add to `mcp_servers.json` in your project root:
377
+ ```json
378
+ {
379
+ "servers": [
380
+ {
381
+ "name": "contextforge",
382
+ "transport": "stdio",
383
+ "command": "cf-mcp",
384
+ "enabled": true
385
+ }
386
+ ]
387
+ }
388
+ ```
389
+
390
+ ### Tools
391
+
392
+ | Tool | Description |
393
+ |---|---|
394
+ | `list_sessions` | List indexed sessions; filter by `tool`, limit up to 200 |
395
+ | `get_session` | Full detail for a single session by ID |
396
+ | `get_session_tokens` | Per-turn token breakdown; optionally return only the N heaviest turns |
397
+ | `get_token_analytics` | Aggregated token stats across all sessions for a time window |
398
+ | `get_activity_timeline` | Session count and token usage bucketed over time |
399
+ | `get_top_projects` | Top projects by session count within a time window |
400
+ | `compare_sessions` | Side-by-side token comparison for 2–10 sessions |
401
+
402
+ All tools are **read-only** and accept a `window` parameter of `7d`, `30d`, `6m`, or `1y` where applicable.
403
+
404
+ ### Example usage
405
+
406
+ ```
407
+ # In Claude Code with the MCP server registered:
408
+ "Show me token usage for all my Claude Code sessions this month"
409
+ "Compare sessions <id1> and <id2> side by side"
410
+ "What are my top 5 projects by session count?"
411
+ ```
412
+
307
413
  ## Troubleshooting
308
414
 
309
415
  ### `command not found: cf`
@@ -276,6 +276,111 @@ Press `t` on any session to open the transfer panel. Choose:
276
276
  - **Preview** — shows the exact shell command (no side effects)
277
277
  - **Execute** — builds the bundle and launches the target tool
278
278
 
279
+ ## MCP Server
280
+
281
+ ContextForge ships a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes session data and token analytics to LLM agents.
282
+
283
+ ### Setup
284
+
285
+ ```bash
286
+ # Run directly (after installation)
287
+ cf-mcp
288
+
289
+ # From source
290
+ uv run python -m contextforge.mcp_server
291
+ ```
292
+
293
+ Register the server with your agentic tool of choice:
294
+
295
+ #### Claude Code
296
+
297
+ Add to `~/.claude/claude_desktop_config.json` (global) or `.claude/mcp.json` (project):
298
+ ```json
299
+ {
300
+ "mcpServers": {
301
+ "contextforge": {
302
+ "command": "cf-mcp"
303
+ }
304
+ }
305
+ }
306
+ ```
307
+
308
+ #### Cursor
309
+
310
+ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
311
+ ```json
312
+ {
313
+ "mcpServers": {
314
+ "contextforge": {
315
+ "command": "cf-mcp"
316
+ }
317
+ }
318
+ }
319
+ ```
320
+
321
+ Restart Cursor fully after saving.
322
+
323
+ #### Codex CLI
324
+
325
+ Add to `~/.codex/config.toml` (global) or `.codex/config.toml` (project):
326
+ ```toml
327
+ [mcp_servers.contextforge]
328
+ command = "cf-mcp"
329
+ enabled = true
330
+ ```
331
+
332
+ #### Gemini CLI
333
+
334
+ Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
335
+ ```json
336
+ {
337
+ "mcpServers": {
338
+ "contextforge": {
339
+ "command": "cf-mcp"
340
+ }
341
+ }
342
+ }
343
+ ```
344
+
345
+ #### Antigravity
346
+
347
+ Add to `mcp_servers.json` in your project root:
348
+ ```json
349
+ {
350
+ "servers": [
351
+ {
352
+ "name": "contextforge",
353
+ "transport": "stdio",
354
+ "command": "cf-mcp",
355
+ "enabled": true
356
+ }
357
+ ]
358
+ }
359
+ ```
360
+
361
+ ### Tools
362
+
363
+ | Tool | Description |
364
+ |---|---|
365
+ | `list_sessions` | List indexed sessions; filter by `tool`, limit up to 200 |
366
+ | `get_session` | Full detail for a single session by ID |
367
+ | `get_session_tokens` | Per-turn token breakdown; optionally return only the N heaviest turns |
368
+ | `get_token_analytics` | Aggregated token stats across all sessions for a time window |
369
+ | `get_activity_timeline` | Session count and token usage bucketed over time |
370
+ | `get_top_projects` | Top projects by session count within a time window |
371
+ | `compare_sessions` | Side-by-side token comparison for 2–10 sessions |
372
+
373
+ All tools are **read-only** and accept a `window` parameter of `7d`, `30d`, `6m`, or `1y` where applicable.
374
+
375
+ ### Example usage
376
+
377
+ ```
378
+ # In Claude Code with the MCP server registered:
379
+ "Show me token usage for all my Claude Code sessions this month"
380
+ "Compare sessions <id1> and <id2> side by side"
381
+ "What are my top 5 projects by session count?"
382
+ ```
383
+
279
384
  ## Troubleshooting
280
385
 
281
386
  ### `command not found: cf`
@@ -578,6 +578,31 @@ def refresh(
578
578
  )
579
579
 
580
580
 
581
+ # ---------------------------------------------------------------------------
582
+ # cf mcp
583
+ # ---------------------------------------------------------------------------
584
+
585
+ @app.command(name="mcp")
586
+ def mcp_server():
587
+ """Start the ContextForge MCP server (stdio transport).
588
+
589
+ Exposes session data and token analysis as MCP tools so LLM agents
590
+ can query token usage, list sessions, and pull analytics directly.
591
+
592
+ Add to your Claude Code MCP config:
593
+ {
594
+ "mcpServers": {
595
+ "contextforge": {
596
+ "command": "cf",
597
+ "args": ["mcp"]
598
+ }
599
+ }
600
+ }
601
+ """
602
+ from contextforge.mcp_server import main as _run_mcp
603
+ _run_mcp()
604
+
605
+
581
606
  # ---------------------------------------------------------------------------
582
607
  # cf dashboard
583
608
  # ---------------------------------------------------------------------------
@@ -0,0 +1,219 @@
1
+ """ContextForge MCP server — exposes token analysis and session tools to LLM agents."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from contextforge.core import analytics as analytics_module
9
+ from contextforge.core import db as db_module
10
+ from contextforge.core.token_analyzer import analyze_tokens
11
+ from contextforge.models.config import ForgeConfig
12
+
13
+ mcp = FastMCP(
14
+ "contextforge",
15
+ instructions=(
16
+ "ContextForge provides read-only access to indexed agentic-tool sessions "
17
+ "and their token usage. Start with list_sessions to discover what's available, "
18
+ "then use get_session_tokens for per-turn breakdown or get_token_analytics "
19
+ "for aggregated stats across all tools."
20
+ ),
21
+ )
22
+
23
+
24
+ def _db():
25
+ cfg = ForgeConfig()
26
+ return db_module.get_db(cfg.db_path)
27
+
28
+
29
+ @mcp.tool()
30
+ def list_sessions(tool: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
31
+ """List indexed sessions with basic token summaries.
32
+
33
+ Args:
34
+ tool: Filter by tool name (claude_code, codex, gemini, etc.). Omit for all tools.
35
+ limit: Maximum sessions to return (default 50, max 200).
36
+ """
37
+ db = _db()
38
+ rows = db_module.get_sessions(db, tool=tool, limit=min(limit, 200))
39
+ return [
40
+ {
41
+ "id": r["id"],
42
+ "tool": r["tool"],
43
+ "title": r.get("title") or "",
44
+ "cwd": r.get("cwd") or "",
45
+ "token_count": r.get("token_count") or 0,
46
+ "updated_at": r.get("updated_at"),
47
+ "status": r.get("status") or "",
48
+ "summary": r.get("summary") or "",
49
+ }
50
+ for r in rows
51
+ ]
52
+
53
+
54
+ @mcp.tool()
55
+ def get_session(session_id: str) -> dict[str, Any] | None:
56
+ """Get full details for a single session by ID.
57
+
58
+ Args:
59
+ session_id: The exact session ID from list_sessions.
60
+ """
61
+ db = _db()
62
+ row = db_module.get_session(db, session_id)
63
+ return dict(row) if row else None
64
+
65
+
66
+ @mcp.tool()
67
+ def get_session_tokens(session_id: str, top: int = 0) -> dict[str, Any] | None:
68
+ """Get per-turn token breakdown for a session.
69
+
70
+ Returns total tokens, per-role totals and averages, and a list of turns
71
+ with individual counts for text, tool calls, and tool results.
72
+
73
+ Args:
74
+ session_id: The session ID.
75
+ top: If > 0, return only the N heaviest turns (sorted by turn number).
76
+ """
77
+ db = _db()
78
+ report = analyze_tokens(db, session_id)
79
+ if report is None:
80
+ return None
81
+
82
+ turns = report.turns
83
+ if top > 0:
84
+ turns = sorted(turns, key=lambda t: t.tokens, reverse=True)[:top]
85
+ turns = sorted(turns, key=lambda t: t.turn)
86
+
87
+ return {
88
+ "session_id": report.session_id,
89
+ "tool": report.tool,
90
+ "title": report.title,
91
+ "total_tokens": report.total,
92
+ "user_tokens": report.user_total,
93
+ "assistant_tokens": report.assistant_total,
94
+ "tool_call_tokens": report.tool_call_total,
95
+ "tool_result_tokens": report.tool_result_total,
96
+ "turn_count": report.turn_count,
97
+ "avg_user_tokens": round(report.avg_user, 1),
98
+ "avg_assistant_tokens": round(report.avg_assistant, 1),
99
+ "has_tool_data": report.has_tool_data,
100
+ "heaviest_turn": (
101
+ {
102
+ "turn": report.max_turn.turn,
103
+ "role": report.max_turn.role,
104
+ "tokens": report.max_turn.tokens,
105
+ "preview": report.max_turn.content_preview,
106
+ }
107
+ if report.max_turn
108
+ else None
109
+ ),
110
+ "turns": [
111
+ {
112
+ "turn": t.turn,
113
+ "role": t.role,
114
+ "tokens": t.tokens,
115
+ "text_tokens": t.text_tokens,
116
+ "tool_call_tokens": t.tool_call_tokens,
117
+ "tool_result_tokens": t.tool_result_tokens,
118
+ "cumulative": t.cumulative,
119
+ "preview": t.content_preview,
120
+ }
121
+ for t in turns
122
+ ],
123
+ }
124
+
125
+
126
+ @mcp.tool()
127
+ def get_token_analytics(window: str = "30d") -> dict[str, Any]:
128
+ """Get aggregated token usage statistics across all sessions.
129
+
130
+ Args:
131
+ window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
132
+ """
133
+ db = _db()
134
+ overview = analytics_module.get_overview(db, window=window)
135
+ return {
136
+ "window": window,
137
+ "total_sessions": overview.total_sessions,
138
+ "total_tokens": overview.total_tokens,
139
+ "active_tools": overview.active_tools,
140
+ "session_count_by_tool": overview.session_count_by_tool,
141
+ "token_sum_by_tool": overview.token_sum_by_tool,
142
+ "date_range": {
143
+ "start": overview.date_range[0].isoformat() if overview.date_range else None,
144
+ "end": overview.date_range[1].isoformat() if overview.date_range else None,
145
+ },
146
+ }
147
+
148
+
149
+ @mcp.tool()
150
+ def get_activity_timeline(window: str = "30d") -> list[dict[str, Any]]:
151
+ """Get session count and token usage bucketed over time.
152
+
153
+ Returns one bucket per day (7d/30d), per week (6m), or per month (1y).
154
+
155
+ Args:
156
+ window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
157
+ """
158
+ db = _db()
159
+ buckets = analytics_module.get_activity_over_time(db, window=window)
160
+ return [
161
+ {"label": b.label, "sessions": b.count, "tokens": b.tokens}
162
+ for b in buckets
163
+ ]
164
+
165
+
166
+ @mcp.tool()
167
+ def get_top_projects(window: str = "30d", top_n: int = 5) -> list[dict[str, Any]]:
168
+ """Get top projects by session count within a time window.
169
+
170
+ Args:
171
+ window: Time window — one of "7d", "30d", "6m", "1y" (default "30d").
172
+ top_n: Number of top projects to return (default 5).
173
+ """
174
+ db = _db()
175
+ projects = analytics_module.get_top_projects(db, window=window, top_n=top_n)
176
+ return [{"project": p.project, "session_count": p.count} for p in projects]
177
+
178
+
179
+ @mcp.tool()
180
+ def compare_sessions(session_ids: list[str]) -> list[dict[str, Any]]:
181
+ """Compare token usage across multiple sessions side-by-side.
182
+
183
+ Returns one entry per session with key metrics, sorted by total tokens descending.
184
+
185
+ Args:
186
+ session_ids: List of session IDs to compare (2–10 sessions recommended).
187
+ """
188
+ db = _db()
189
+ results: list[dict[str, Any]] = []
190
+ for sid in session_ids:
191
+ report = analyze_tokens(db, sid)
192
+ if report is None:
193
+ results.append({"session_id": sid, "error": "not found"})
194
+ continue
195
+ results.append({
196
+ "session_id": sid,
197
+ "tool": report.tool,
198
+ "title": report.title,
199
+ "total_tokens": report.total,
200
+ "turn_count": report.turn_count,
201
+ "user_tokens": report.user_total,
202
+ "assistant_tokens": report.assistant_total,
203
+ "tool_call_tokens": report.tool_call_total,
204
+ "tool_result_tokens": report.tool_result_total,
205
+ "avg_tokens_per_turn": (
206
+ round(report.total / report.turn_count, 1) if report.turn_count else 0
207
+ ),
208
+ "heaviest_turn_tokens": report.max_turn.tokens if report.max_turn else 0,
209
+ })
210
+ results.sort(key=lambda r: r.get("total_tokens", 0), reverse=True)
211
+ return results
212
+
213
+
214
+ def main() -> None:
215
+ mcp.run()
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
@@ -0,0 +1,25 @@
1
+ """Token counting utilities using tiktoken."""
2
+ from __future__ import annotations
3
+
4
+ import tiktoken
5
+
6
+ _ENCODING = None
7
+
8
+
9
+ def _get_encoding() -> tiktoken.Encoding:
10
+ global _ENCODING
11
+ if _ENCODING is None:
12
+ _ENCODING = tiktoken.get_encoding("cl100k_base")
13
+ return _ENCODING
14
+
15
+
16
+ def count_tokens(text: str) -> int:
17
+ return len(_get_encoding().encode(text))
18
+
19
+
20
+ def truncate_to_budget(text: str, budget: int) -> str:
21
+ enc = _get_encoding()
22
+ tokens = enc.encode(text)
23
+ if len(tokens) <= budget:
24
+ return text
25
+ return enc.decode(tokens[:budget])
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.2.5"
3
+ version = "0.3.0"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -21,6 +21,7 @@ classifiers = [
21
21
  requires-python = ">=3.13"
22
22
  dependencies = [
23
23
  "anthropic>=0.89.0",
24
+ "mcp>=1.9.0",
24
25
  "pydantic>=2.12.5",
25
26
  "rich>=14.3.3",
26
27
  "sqlite-utils>=3.39",
@@ -36,6 +37,7 @@ Repository = "https://github.com/emmver/contextforge"
36
37
 
37
38
  [project.scripts]
38
39
  cf = "contextforge.cli:app"
40
+ cf-mcp = "contextforge.mcp_server:main"
39
41
 
40
42
  [build-system]
41
43
  requires = ["hatchling"]
@@ -0,0 +1,32 @@
1
+ """Shared pytest fixtures — mocks tiktoken for offline test environments."""
2
+ from __future__ import annotations
3
+
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+
9
+ class _FakeEncoding:
10
+ """Minimal tiktoken encoding stand-in using word-count approximation."""
11
+
12
+ def encode(self, text: str) -> list[int]:
13
+ return list(range(max(1, len(text.split()))))
14
+
15
+ def decode(self, tokens: list[int]) -> str:
16
+ # Best-effort: can't reconstruct text, but truncate_to_budget tests
17
+ # only need a shorter string back.
18
+ return " ".join(["x"] * len(tokens))
19
+
20
+
21
+ _FAKE_ENCODING = _FakeEncoding()
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def mock_tiktoken():
26
+ """Prevent tiktoken from downloading its BPE vocab file over the network.
27
+
28
+ Patches _get_encoding() at the source so every importer of count_tokens
29
+ gets the stub regardless of how they bound the function.
30
+ """
31
+ with patch("contextforge.utils.tokens._get_encoding", return_value=_FAKE_ENCODING):
32
+ yield