upfront 0.1.0__py3-none-any.whl

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.
upfront/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """upfront: see what your coding agent loads before your first prompt."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from upfront.report import Report, profile, profile_tools_file
6
+
7
+ __all__ = ["Report", "__version__", "profile", "profile_tools_file"]
upfront/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """Command-line interface: ``upfront [PATH]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from upfront import __version__
12
+ from upfront.discover import HARNESSES, discover
13
+ from upfront.fetch import fetch_all
14
+ from upfront.report import Report, build_report, profile_tools_file, servers_to_launch
15
+
16
+ app = typer.Typer(add_completion=False, rich_markup_mode=None)
17
+
18
+
19
+ LABEL_WIDTH = 46
20
+
21
+
22
+ def _n(x: int) -> str:
23
+ return f"≈ {x:,}"
24
+
25
+
26
+ def _row(label: str, value: str = "", note: str = "") -> str:
27
+ if len(label) > LABEL_WIDTH:
28
+ label = label[: LABEL_WIDTH - 1] + "…"
29
+ return f"{label:<{LABEL_WIDTH}} {value:>9} {note}".rstrip()
30
+
31
+
32
+ def _tidy(text: str, root: str) -> str:
33
+ """Shorten paths so pasted output doesn't leak the home directory."""
34
+ for prefix in (str(Path(root).resolve()), str(Path(root))):
35
+ if prefix not in ("", "."):
36
+ text = text.replace(prefix + "/", "")
37
+ return text.replace(str(Path.home()), "~")
38
+
39
+
40
+ def render(report: Report, top: int) -> str:
41
+ root = report.root
42
+ header = root if root not in ("", ".") else _tidy(str(Path(root).resolve()), "/")
43
+ lines = [f"upfront {__version__} · {header} · tokens {report.tokenizer}"]
44
+ shown = [h for h in report.harnesses if not h.empty]
45
+ if not shown:
46
+ lines += ["", "Nothing configured: no MCP servers, skills or instruction files found."]
47
+ for h in shown:
48
+ lines += ["", _row(h.harness, _n(h.total), "before your first prompt")]
49
+ for s in h.servers:
50
+ label = f" mcp {s.name} ({s.scope})"
51
+ if s.status == "ok":
52
+ lines.append(
53
+ _row(f" mcp {s.name} ({len(s.tools)} tools, {s.scope})", _n(s.tokens))
54
+ )
55
+ lines += [_row(f" {t.name}", _n(t.tokens)) for t in s.tools[:top]]
56
+ elif s.status == "skipped":
57
+ lines.append(_row(label, "-", "not measured: no consent"))
58
+ else:
59
+ lines.append(_row(label, "-", f"not measured: {_tidy(s.error or '', root)}"))
60
+ if h.skills_count:
61
+ label = f" skills ({h.skills_count}, name + description)"
62
+ body = f"bodies {_n(h.skills_body_tokens)} load on demand"
63
+ lines.append(_row(label, _n(h.skills_metadata_tokens), body))
64
+ for file in h.instructions:
65
+ lines.append(_row(f" {_tidy(file.path, root)}", _n(file.tokens)))
66
+ lines += [f" ! {_tidy(e, root)}" for e in h.errors]
67
+ findings = [f for h in report.harnesses for f in h.findings]
68
+ if findings:
69
+ lines += ["", "compatibility"]
70
+ for f in findings:
71
+ where = ".".join(x for x in (f.server, f.tool) if x) or "-"
72
+ lines.append(f" {f.severity:<7} {f.harness:<11} {where}: {f.message}")
73
+ lines.append(f" [{f.rule}] {f.source}")
74
+ if any(s.status == "skipped" for h in shown for s in h.servers):
75
+ lines += [
76
+ "",
77
+ "Servers were not started. Run with --yes to launch them and measure their tools.",
78
+ ]
79
+ return "\n".join(lines)
80
+
81
+
82
+ def _version(value: bool) -> None:
83
+ if value:
84
+ typer.echo(f"upfront {__version__}")
85
+ raise typer.Exit()
86
+
87
+
88
+ @app.command()
89
+ def main(
90
+ path: Annotated[Path, typer.Argument(help="Project directory.")] = Path("."),
91
+ harness: Annotated[
92
+ list[str] | None,
93
+ typer.Option("--harness", "-H", help=f"Only these harnesses: {', '.join(HARNESSES)}."),
94
+ ] = None,
95
+ yes: Annotated[
96
+ bool,
97
+ typer.Option("--yes", "-y", help="Start/connect to configured servers without asking."),
98
+ ] = False,
99
+ as_json: Annotated[bool, typer.Option("--json", help="Print the report as JSON.")] = False,
100
+ budget: Annotated[
101
+ int | None, typer.Option(help="Exit 1 if any harness loads more tokens than this.")
102
+ ] = None,
103
+ strict: Annotated[
104
+ bool, typer.Option(help="Exit 1 on error-level compatibility findings.")
105
+ ] = False,
106
+ tools_file: Annotated[
107
+ Path | None, typer.Option(help="Measure a saved tools/list JSON instead of configs.")
108
+ ] = None,
109
+ top: Annotated[int, typer.Option(help="Heaviest tools to list per server.")] = 3,
110
+ timeout: Annotated[float, typer.Option(help="Seconds per server.")] = 20,
111
+ version: Annotated[
112
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
113
+ ] = False,
114
+ ) -> None:
115
+ """See what your coding agents load before your first prompt."""
116
+ unknown = set(harness or []) - set(HARNESSES)
117
+ if unknown:
118
+ raise typer.BadParameter(f"unknown harness {', '.join(sorted(unknown))}", param_hint="-H")
119
+
120
+ if tools_file:
121
+ report = profile_tools_file(tools_file, only=harness)
122
+ else:
123
+ d = discover(path, only=harness)
124
+ specs = servers_to_launch(d)
125
+ launch = yes
126
+ if specs and not yes and sys.stdin.isatty():
127
+ typer.echo("upfront will start or connect to these MCP servers to read their tools:")
128
+ for s in specs:
129
+ typer.echo(f" [{s.harness}] {s.name}: {s.target}")
130
+ launch = typer.confirm("Continue?", default=False)
131
+ report = build_report(d, fetch_all(specs, timeout=timeout) if launch else [])
132
+
133
+ typer.echo(report.to_json() if as_json else render(report, top))
134
+
135
+ failed = False
136
+ if budget is not None:
137
+ for h in report.harnesses:
138
+ if h.total > budget:
139
+ failed = True
140
+ if not as_json:
141
+ typer.echo(
142
+ f"{h.harness}: {h.total:,} tokens is over budget ({budget:,})", err=True
143
+ )
144
+ if strict and any(f.severity == "error" for h in report.harnesses for f in h.findings):
145
+ failed = True
146
+ if failed:
147
+ raise typer.Exit(1)
upfront/compat.py ADDED
@@ -0,0 +1,227 @@
1
+ """Static compatibility checks for MCP tool definitions, per client.
2
+
3
+ Every rule cites the documentation or the *open* issue it comes from. Problems that clients
4
+ have already fixed (several old ``$defs``/64-character reports) are deliberately not rules:
5
+ a false "this breaks" is worse than no warning.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass
13
+ from typing import Any, Literal
14
+
15
+ from upfront.discover import Skill
16
+
17
+ Severity = Literal["error", "warning", "info"]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Rule:
22
+ id: str
23
+ harnesses: tuple[str, ...]
24
+ severity: Severity
25
+ summary: str
26
+ source: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Finding:
31
+ rule: str
32
+ harness: str
33
+ severity: Severity
34
+ server: str
35
+ tool: str
36
+ message: str
37
+ source: str
38
+
39
+
40
+ RULES: dict[str, Rule] = {
41
+ r.id: r
42
+ for r in (
43
+ Rule(
44
+ "openai-tool-name",
45
+ ("codex",),
46
+ "error",
47
+ "OpenAI function names allow a-z, A-Z, 0-9, _ and -, up to 64 characters",
48
+ "https://github.com/openai/openai-python/blob/main/src/openai/types/shared_params/function_definition.py",
49
+ ),
50
+ Rule(
51
+ "anthropic-tool-name",
52
+ ("claude-code",),
53
+ "error",
54
+ "Claude tool names must match ^[a-zA-Z0-9_-]{1,128}$ after the mcp__<server>__ prefix",
55
+ "https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools",
56
+ ),
57
+ Rule(
58
+ "gemini-tool-name",
59
+ ("gemini-cli",),
60
+ "error",
61
+ "Gemini function names start with a letter or _; then a-zA-Z0-9_.:-, up to 128",
62
+ "https://github.com/googleapis/python-genai/blob/main/google/genai/types.py",
63
+ ),
64
+ Rule(
65
+ "claude-ref-params",
66
+ ("claude-code",),
67
+ "warning",
68
+ "Parameters defined through $ref are sent to the server as JSON strings",
69
+ "https://github.com/anthropics/claude-code/issues/18260",
70
+ ),
71
+ Rule(
72
+ "opencode-unknown-format",
73
+ ("opencode",),
74
+ "info",
75
+ "Non-standard JSON Schema 'format' values make OpenCode log warnings",
76
+ "https://github.com/anomalyco/opencode/issues/15161",
77
+ ),
78
+ Rule(
79
+ "codex-skills-budget",
80
+ ("codex",),
81
+ "warning",
82
+ "Codex caps the initial skills list at 2% of the context window (8,000 characters "
83
+ "when the window is unknown) and shortens or drops skills beyond it",
84
+ "https://learn.chatgpt.com/docs/build-skills",
85
+ ),
86
+ )
87
+ }
88
+
89
+ _OPENAI_NAME = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
90
+ _ANTHROPIC_NAME = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
91
+ _SERVER_SEGMENT = re.compile(r"[^a-zA-Z0-9_-]")
92
+ _GEMINI_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.:-]{0,127}$")
93
+
94
+ # JSON Schema 2020-12 defined formats (validation vocabulary, section 7.3).
95
+ STANDARD_FORMATS = frozenset(
96
+ {
97
+ "date-time",
98
+ "date",
99
+ "time",
100
+ "duration",
101
+ "email",
102
+ "idn-email",
103
+ "hostname",
104
+ "idn-hostname",
105
+ "ipv4",
106
+ "ipv6",
107
+ "uri",
108
+ "uri-reference",
109
+ "iri",
110
+ "iri-reference",
111
+ "uuid",
112
+ "uri-template",
113
+ "json-pointer",
114
+ "relative-json-pointer",
115
+ "regex",
116
+ }
117
+ )
118
+
119
+ CODEX_SKILLS_FALLBACK_CHARS = 8000
120
+
121
+
122
+ def _walk(node: Any) -> Iterator[dict[str, Any]]:
123
+ if isinstance(node, dict):
124
+ yield node
125
+ for v in node.values():
126
+ yield from _walk(v)
127
+ elif isinstance(node, list):
128
+ for v in node:
129
+ yield from _walk(v)
130
+
131
+
132
+ def _finding(rule_id: str, harness: str, server: str, tool: str, message: str) -> Finding:
133
+ r = RULES[rule_id]
134
+ return Finding(rule_id, harness, r.severity, server, tool, message, r.source)
135
+
136
+
137
+ def check_tools(harness: str, server: str, tools: list[dict[str, Any]]) -> list[Finding]:
138
+ """Findings for one server's tools as seen by ``harness``."""
139
+ out: list[Finding] = []
140
+ for tool in tools:
141
+ name = str(tool.get("name", ""))
142
+ schema = tool.get("inputSchema") or {}
143
+ nodes = list(_walk(schema))
144
+ if harness == "codex" and not _OPENAI_NAME.match(name):
145
+ out.append(
146
+ _finding(
147
+ "openai-tool-name",
148
+ harness,
149
+ server,
150
+ name,
151
+ f"'{name}' ({len(name)} chars) is not a valid OpenAI function name",
152
+ )
153
+ )
154
+ if harness == "claude-code":
155
+ full = f"mcp__{_SERVER_SEGMENT.sub('_', server)}__{name}"
156
+ if not _ANTHROPIC_NAME.match(full):
157
+ out.append(
158
+ _finding(
159
+ "anthropic-tool-name",
160
+ harness,
161
+ server,
162
+ name,
163
+ f"'{full}' ({len(full)} chars) is not a valid Claude tool name",
164
+ )
165
+ )
166
+ if any("$ref" in n for n in nodes):
167
+ out.append(
168
+ _finding(
169
+ "claude-ref-params",
170
+ harness,
171
+ server,
172
+ name,
173
+ "uses $ref; Claude Code may send those arguments as strings",
174
+ )
175
+ )
176
+ if harness == "gemini-cli" and not _GEMINI_NAME.match(name):
177
+ out.append(
178
+ _finding(
179
+ "gemini-tool-name",
180
+ harness,
181
+ server,
182
+ name,
183
+ f"'{name}' is not a valid Gemini function name",
184
+ )
185
+ )
186
+ if harness == "opencode":
187
+ odd = sorted(
188
+ {
189
+ str(n["format"])
190
+ for n in nodes
191
+ if isinstance(n.get("format"), str) and n["format"] not in STANDARD_FORMATS
192
+ }
193
+ )
194
+ if odd:
195
+ out.append(
196
+ _finding(
197
+ "opencode-unknown-format",
198
+ harness,
199
+ server,
200
+ name,
201
+ f"non-standard format: {', '.join(odd)}",
202
+ )
203
+ )
204
+ return out
205
+
206
+
207
+ def codex_skills_chars(skills: list[Skill]) -> int:
208
+ """Approximate size of Codex's initial skills list: name, description and path per skill."""
209
+ return sum(len(s.name) + len(s.description) + len(str(s.path)) for s in skills)
210
+
211
+
212
+ def check_skills(harness: str, skills: list[Skill]) -> list[Finding]:
213
+ if harness != "codex":
214
+ return []
215
+ chars = codex_skills_chars(skills)
216
+ if chars <= CODEX_SKILLS_FALLBACK_CHARS:
217
+ return []
218
+ return [
219
+ _finding(
220
+ "codex-skills-budget",
221
+ harness,
222
+ "",
223
+ "",
224
+ f"{len(skills)} skills ≈ {chars:,} characters of metadata, over Codex's "
225
+ f"{CODEX_SKILLS_FALLBACK_CHARS:,}-character fallback budget",
226
+ )
227
+ ]
upfront/discover.py ADDED
@@ -0,0 +1,439 @@
1
+ """Find the MCP servers, skills and instruction files each coding agent loads up front.
2
+
3
+ Config formats follow each harness's documentation:
4
+
5
+ - Claude Code: https://code.claude.com/docs/en/mcp (``.mcp.json``, ``~/.claude.json``)
6
+ - Codex: https://learn.chatgpt.com/docs/extend/mcp (``[mcp_servers.<name>]`` in
7
+ ``.codex/config.toml`` and ``~/.codex/config.toml``); skills in ``.agents/skills``
8
+ - Gemini CLI: https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md
9
+ (``mcpServers`` in ``.gemini/settings.json``; ``url`` is SSE, ``httpUrl`` is Streamable HTTP)
10
+ - OpenCode: https://opencode.ai/docs/mcp-servers (``mcp`` in ``opencode.json[c]``)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import tomllib
19
+ from collections.abc import Iterable, Mapping
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Literal
23
+
24
+ Harness = Literal["claude-code", "codex", "gemini-cli", "opencode"]
25
+ HARNESSES: tuple[Harness, ...] = ("claude-code", "codex", "gemini-cli", "opencode")
26
+ Transport = Literal["stdio", "http", "sse"]
27
+ Scope = Literal["project", "local", "user"]
28
+
29
+
30
+ @dataclass
31
+ class ServerSpec:
32
+ """One configured MCP server, as a given harness would start it."""
33
+
34
+ harness: Harness
35
+ name: str
36
+ scope: Scope
37
+ source: Path
38
+ transport: Transport
39
+ command: str | None = None
40
+ args: list[str] = field(default_factory=list)
41
+ env: dict[str, str] = field(default_factory=dict)
42
+ cwd: str | None = None
43
+ url: str | None = None
44
+ headers: dict[str, str] = field(default_factory=dict)
45
+
46
+ @property
47
+ def target(self) -> str:
48
+ """The command line or URL, as shown to the user before launching."""
49
+ if self.transport == "stdio":
50
+ return " ".join([self.command or "", *self.args]).strip()
51
+ return self.url or ""
52
+
53
+
54
+ @dataclass
55
+ class Skill:
56
+ name: str
57
+ description: str
58
+ body: str
59
+ path: Path
60
+
61
+
62
+ @dataclass
63
+ class InstructionFile:
64
+ path: Path
65
+ text: str
66
+
67
+
68
+ @dataclass
69
+ class HarnessContext:
70
+ harness: Harness
71
+ servers: list[ServerSpec] = field(default_factory=list)
72
+ skills: list[Skill] = field(default_factory=list)
73
+ instructions: list[InstructionFile] = field(default_factory=list)
74
+ errors: list[str] = field(default_factory=list)
75
+
76
+
77
+ @dataclass
78
+ class Discovery:
79
+ root: Path
80
+ harnesses: dict[Harness, HarnessContext]
81
+
82
+
83
+ # --------------------------------------------------------------------------- #
84
+ # Small parsers
85
+ # --------------------------------------------------------------------------- #
86
+
87
+ _VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
88
+
89
+
90
+ def expand(value: str, env: Mapping[str, str]) -> str:
91
+ """Claude Code's ``${VAR}`` / ``${VAR:-default}`` expansion; unknown vars stay literal."""
92
+
93
+ def sub(m: re.Match[str]) -> str:
94
+ name, default = m.group(1), m.group(2)
95
+ if name in env:
96
+ return env[name]
97
+ return default if default is not None else m.group(0)
98
+
99
+ return _VAR.sub(sub, value)
100
+
101
+
102
+ def strip_jsonc(text: str) -> str:
103
+ """Remove // and /* */ comments and trailing commas outside of strings."""
104
+ out: list[str] = []
105
+ i, n, in_str = 0, len(text), False
106
+ while i < n:
107
+ c = text[i]
108
+ if in_str:
109
+ out.append(c)
110
+ if c == "\\" and i + 1 < n:
111
+ out.append(text[i + 1])
112
+ i += 1
113
+ elif c == '"':
114
+ in_str = False
115
+ elif c == '"':
116
+ in_str = True
117
+ out.append(c)
118
+ elif text.startswith("//", i):
119
+ while i < n and text[i] != "\n":
120
+ i += 1
121
+ continue
122
+ elif text.startswith("/*", i):
123
+ end = text.find("*/", i + 2)
124
+ i = n if end == -1 else end + 2
125
+ continue
126
+ else:
127
+ out.append(c)
128
+ i += 1
129
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
130
+
131
+
132
+ def _load(path: Path, ctx: HarnessContext, kind: Literal["json", "jsonc", "toml"]) -> Any:
133
+ if not path.is_file():
134
+ return None
135
+ try:
136
+ text = path.read_text(encoding="utf-8")
137
+ if kind == "toml":
138
+ return tomllib.loads(text)
139
+ return json.loads(strip_jsonc(text) if kind == "jsonc" else text)
140
+ except (OSError, ValueError) as e:
141
+ ctx.errors.append(f"{path}: could not parse ({e})")
142
+ return None
143
+
144
+
145
+ def _str_map(value: Any) -> dict[str, str]:
146
+ return {str(k): str(v) for k, v in value.items()} if isinstance(value, dict) else {}
147
+
148
+
149
+ def _str_list(value: Any) -> list[str]:
150
+ return [str(v) for v in value] if isinstance(value, list) else []
151
+
152
+
153
+ # --------------------------------------------------------------------------- #
154
+ # Per-harness readers
155
+ # --------------------------------------------------------------------------- #
156
+
157
+
158
+ def _claude_servers(
159
+ ctx: HarnessContext, entries: Any, scope: Scope, source: Path, env: Mapping[str, str]
160
+ ) -> None:
161
+ if not isinstance(entries, dict):
162
+ return
163
+ for name, e in entries.items():
164
+ if not isinstance(e, dict):
165
+ continue
166
+ kind = str(e.get("type", "stdio" if "command" in e else ""))
167
+
168
+ def x(v: Any) -> str:
169
+ return expand(str(v), env)
170
+
171
+ if kind == "stdio" and "command" in e:
172
+ ctx.servers.append(
173
+ ServerSpec(
174
+ "claude-code",
175
+ name,
176
+ scope,
177
+ source,
178
+ "stdio",
179
+ command=x(e["command"]),
180
+ args=[x(a) for a in _str_list(e.get("args"))],
181
+ env={k: x(v) for k, v in _str_map(e.get("env")).items()},
182
+ )
183
+ )
184
+ elif kind in ("http", "streamable-http", "sse") and "url" in e:
185
+ ctx.servers.append(
186
+ ServerSpec(
187
+ "claude-code",
188
+ name,
189
+ scope,
190
+ source,
191
+ "sse" if kind == "sse" else "http",
192
+ url=x(e["url"]),
193
+ headers={k: x(v) for k, v in _str_map(e.get("headers")).items()},
194
+ )
195
+ )
196
+ else:
197
+ ctx.errors.append(
198
+ f'{source}: server \'{name}\' skipped: needs "command", or "type": '
199
+ '"http"/"sse" with "url" (Claude Code skips it too)'
200
+ )
201
+
202
+
203
+ def _claude(root: Path, home: Path, env: Mapping[str, str]) -> HarnessContext:
204
+ ctx = HarnessContext("claude-code")
205
+ project = _load(root / ".mcp.json", ctx, "json")
206
+ if isinstance(project, dict):
207
+ _claude_servers(ctx, project.get("mcpServers"), "project", root / ".mcp.json", env)
208
+ user = _load(home / ".claude.json", ctx, "json")
209
+ if isinstance(user, dict):
210
+ src = home / ".claude.json"
211
+ _claude_servers(ctx, user.get("mcpServers"), "user", src, env)
212
+ projects = user.get("projects") or {}
213
+ local = projects.get(str(root)) or projects.get(str(root.resolve()))
214
+ if isinstance(local, dict):
215
+ _claude_servers(ctx, local.get("mcpServers"), "local", src, env)
216
+ # Claude Code reads AGENTS.md when the project has no CLAUDE.md.
217
+ _instructions(
218
+ ctx, [root / "CLAUDE.md"] if (root / "CLAUDE.md").is_file() else [root / "AGENTS.md"]
219
+ )
220
+ _instructions(ctx, [home / ".claude" / "CLAUDE.md"])
221
+ _skills(ctx, [root / ".claude" / "skills", home / ".claude" / "skills"])
222
+ return ctx
223
+
224
+
225
+ def _codex(root: Path, home: Path) -> HarnessContext:
226
+ ctx = HarnessContext("codex")
227
+ sources: list[tuple[Path, Scope]] = [
228
+ (root / ".codex" / "config.toml", "project"),
229
+ (home / ".codex" / "config.toml", "user"),
230
+ ]
231
+ for path, scope in sources:
232
+ data = _load(path, ctx, "toml")
233
+ servers = data.get("mcp_servers") if isinstance(data, dict) else None
234
+ for name, e in (servers or {}).items():
235
+ if not isinstance(e, dict) or e.get("enabled") is False:
236
+ continue
237
+ if "command" in e:
238
+ ctx.servers.append(
239
+ ServerSpec(
240
+ "codex",
241
+ name,
242
+ scope,
243
+ path,
244
+ "stdio",
245
+ command=str(e["command"]),
246
+ args=_str_list(e.get("args")),
247
+ env=_str_map(e.get("env")),
248
+ cwd=str(e["cwd"]) if "cwd" in e else None,
249
+ )
250
+ )
251
+ elif "url" in e:
252
+ ctx.servers.append(
253
+ ServerSpec("codex", name, scope, path, "http", url=str(e["url"]))
254
+ )
255
+ _instructions(ctx, [root / "AGENTS.md", home / ".codex" / "AGENTS.md"])
256
+ _skills(ctx, [root / ".agents" / "skills", home / ".agents" / "skills"])
257
+ return ctx
258
+
259
+
260
+ def _gemini(root: Path, home: Path) -> HarnessContext:
261
+ ctx = HarnessContext("gemini-cli")
262
+ sources: list[tuple[Path, Scope]] = [
263
+ (root / ".gemini" / "settings.json", "project"),
264
+ (home / ".gemini" / "settings.json", "user"),
265
+ ]
266
+ for path, scope in sources:
267
+ data = _load(path, ctx, "json")
268
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
269
+ for name, e in (servers or {}).items():
270
+ if not isinstance(e, dict):
271
+ continue
272
+ headers = _str_map(e.get("headers"))
273
+ if "command" in e:
274
+ ctx.servers.append(
275
+ ServerSpec(
276
+ "gemini-cli",
277
+ name,
278
+ scope,
279
+ path,
280
+ "stdio",
281
+ command=str(e["command"]),
282
+ args=_str_list(e.get("args")),
283
+ env=_str_map(e.get("env")),
284
+ cwd=str(e["cwd"]) if "cwd" in e else None,
285
+ )
286
+ )
287
+ elif "httpUrl" in e:
288
+ ctx.servers.append(
289
+ ServerSpec(
290
+ "gemini-cli",
291
+ name,
292
+ scope,
293
+ path,
294
+ "http",
295
+ url=str(e["httpUrl"]),
296
+ headers=headers,
297
+ )
298
+ )
299
+ elif "url" in e:
300
+ ctx.servers.append(
301
+ ServerSpec(
302
+ "gemini-cli", name, scope, path, "sse", url=str(e["url"]), headers=headers
303
+ )
304
+ )
305
+ _instructions(ctx, [root / "GEMINI.md", home / ".gemini" / "GEMINI.md"])
306
+ return ctx
307
+
308
+
309
+ def _opencode(root: Path, home: Path) -> HarnessContext:
310
+ ctx = HarnessContext("opencode")
311
+ user_dir = home / ".config" / "opencode"
312
+ candidates: list[tuple[Path, Scope]] = [
313
+ (root / "opencode.jsonc", "project"),
314
+ (root / "opencode.json", "project"),
315
+ (user_dir / "opencode.jsonc", "user"),
316
+ (user_dir / "opencode.json", "user"),
317
+ ]
318
+ for path, scope in candidates:
319
+ data = _load(path, ctx, "jsonc")
320
+ servers = data.get("mcp") if isinstance(data, dict) else None
321
+ for name, e in (servers or {}).items():
322
+ if not isinstance(e, dict) or e.get("enabled") is False:
323
+ continue
324
+ if e.get("type") == "local":
325
+ cmd = _str_list(e.get("command"))
326
+ if cmd:
327
+ ctx.servers.append(
328
+ ServerSpec(
329
+ "opencode",
330
+ name,
331
+ scope,
332
+ path,
333
+ "stdio",
334
+ command=cmd[0],
335
+ args=cmd[1:],
336
+ env=_str_map(e.get("environment")),
337
+ )
338
+ )
339
+ elif e.get("type") == "remote" and "url" in e:
340
+ ctx.servers.append(
341
+ ServerSpec(
342
+ "opencode",
343
+ name,
344
+ scope,
345
+ path,
346
+ "http",
347
+ url=str(e["url"]),
348
+ headers=_str_map(e.get("headers")),
349
+ )
350
+ )
351
+ _instructions(ctx, [root / "AGENTS.md", user_dir / "AGENTS.md"])
352
+ _skills(
353
+ ctx,
354
+ [
355
+ root / ".opencode" / "skills",
356
+ root / ".claude" / "skills",
357
+ root / ".agents" / "skills",
358
+ user_dir / "skills",
359
+ home / ".claude" / "skills",
360
+ home / ".agents" / "skills",
361
+ ],
362
+ )
363
+ return ctx
364
+
365
+
366
+ # --------------------------------------------------------------------------- #
367
+ # Skills and instruction files
368
+ # --------------------------------------------------------------------------- #
369
+
370
+ _FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.S)
371
+
372
+
373
+ def parse_skill(path: Path) -> Skill:
374
+ text = path.read_text(encoding="utf-8")
375
+ meta: dict[str, str] = {}
376
+ body = text
377
+ m = _FRONTMATTER.match(text)
378
+ if m:
379
+ body = text[m.end() :]
380
+ for line in m.group(1).splitlines():
381
+ key, sep, value = line.partition(":")
382
+ if sep and not line.startswith((" ", "\t")):
383
+ meta[key.strip()] = value.strip().strip("\"'")
384
+ return Skill(meta.get("name", path.parent.name), meta.get("description", ""), body, path)
385
+
386
+
387
+ def _skills(ctx: HarnessContext, dirs: Iterable[Path]) -> None:
388
+ seen = {s.name for s in ctx.skills}
389
+ for d in dirs:
390
+ for path in sorted(d.glob("*/SKILL.md")) if d.is_dir() else []:
391
+ try:
392
+ s = parse_skill(path)
393
+ except (OSError, UnicodeDecodeError) as e:
394
+ ctx.errors.append(f"{path}: could not read ({e})")
395
+ continue
396
+ if s.name not in seen: # the first location wins, like the harnesses' precedence
397
+ seen.add(s.name)
398
+ ctx.skills.append(s)
399
+
400
+
401
+ def _instructions(ctx: HarnessContext, paths: Iterable[Path]) -> None:
402
+ for p in paths:
403
+ if p.is_file():
404
+ try:
405
+ ctx.instructions.append(InstructionFile(p, p.read_text(encoding="utf-8")))
406
+ except (OSError, UnicodeDecodeError) as e:
407
+ ctx.errors.append(f"{p}: could not read ({e})")
408
+
409
+
410
+ # --------------------------------------------------------------------------- #
411
+ # Entry point
412
+ # --------------------------------------------------------------------------- #
413
+
414
+
415
+ def discover(
416
+ root: Path,
417
+ *,
418
+ home: Path | None = None,
419
+ env: Mapping[str, str] | None = None,
420
+ only: Iterable[str] | None = None,
421
+ ) -> Discovery:
422
+ """Read every supported config under ``root`` (project) and ``home`` (user scope)."""
423
+ home = home or Path.home()
424
+ env = dict(os.environ) if env is None else env
425
+ wanted = [h for h in HARNESSES if only is None or h in set(only)]
426
+ readers: dict[Harness, Any] = {
427
+ "claude-code": lambda: _claude(root, home, env),
428
+ "codex": lambda: _codex(root, home),
429
+ "gemini-cli": lambda: _gemini(root, home),
430
+ "opencode": lambda: _opencode(root, home),
431
+ }
432
+ found: dict[Harness, HarnessContext] = {h: readers[h]() for h in wanted}
433
+ for ctx in found.values():
434
+ for spec in ctx.servers:
435
+ # Agents start stdio servers from the project directory; relative cwds are
436
+ # resolved against it (Gemini CLI documents "cwd": "./server-directory").
437
+ if spec.transport == "stdio":
438
+ spec.cwd = str(root / spec.cwd) if spec.cwd else str(root)
439
+ return Discovery(root, found)
upfront/fetch.py ADDED
@@ -0,0 +1,132 @@
1
+ """Connect to MCP servers and collect their full ``tools/list``, exactly as a client would."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from contextlib import AbstractAsyncContextManager
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal
9
+
10
+ import anyio
11
+ import httpx2
12
+ from mcp import Client, StdioServerParameters
13
+ from mcp.client.sse import sse_client
14
+ from mcp.client.stdio import stdio_client
15
+ from mcp.client.streamable_http import streamable_http_client
16
+
17
+ from upfront.discover import ServerSpec
18
+
19
+ Status = Literal["ok", "error", "skipped"]
20
+
21
+
22
+ @dataclass
23
+ class ServerResult:
24
+ spec: ServerSpec
25
+ status: Status
26
+ tools: list[dict[str, Any]] = field(default_factory=list)
27
+ error: str | None = None
28
+
29
+
30
+ def _key(spec: ServerSpec) -> tuple[Any, ...]:
31
+ return (
32
+ spec.transport,
33
+ spec.command,
34
+ tuple(spec.args),
35
+ tuple(sorted(spec.env.items())),
36
+ spec.cwd,
37
+ spec.url,
38
+ tuple(sorted(spec.headers.items())),
39
+ )
40
+
41
+
42
+ def _transport(
43
+ spec: ServerSpec, errlog: Any, statuses: list[int]
44
+ ) -> AbstractAsyncContextManager[Any]:
45
+ async def record(response: httpx2.Response) -> None:
46
+ statuses.append(response.status_code)
47
+
48
+ if spec.transport == "stdio":
49
+ params = StdioServerParameters(
50
+ command=spec.command or "", args=spec.args, env=spec.env or None, cwd=spec.cwd
51
+ )
52
+ return stdio_client(params, errlog=errlog)
53
+ if spec.transport == "sse":
54
+ return sse_client(spec.url or "", headers=dict(spec.headers) or None)
55
+ client = httpx2.AsyncClient(
56
+ headers=spec.headers, follow_redirects=True, event_hooks={"response": [record]}
57
+ )
58
+ return streamable_http_client(spec.url or "", http_client=client)
59
+
60
+
61
+ def _describe(exc: BaseException, statuses: list[int]) -> str:
62
+ """Turn nested anyio/httpx exception groups into one short, actionable line."""
63
+ auth = [c for c in statuses if c in (401, 403)]
64
+ if auth:
65
+ return f"auth required (HTTP {auth[0]}); not measured"
66
+ while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
67
+ exc = exc.exceptions[0]
68
+ text = f"{type(exc).__name__}: {exc}".strip().rstrip(":")
69
+ if isinstance(exc, FileNotFoundError):
70
+ return f"command not found: {exc.filename or exc}"
71
+ return text
72
+
73
+
74
+ async def _list_tools(
75
+ spec: ServerSpec, timeout: float, statuses: list[int], errlog: Any
76
+ ) -> list[dict[str, Any]]:
77
+ tools: list[dict[str, Any]] = []
78
+ with anyio.fail_after(timeout):
79
+ transport = _transport(spec, errlog, statuses)
80
+ async with Client(transport, read_timeout_seconds=timeout) as client:
81
+ cursor: str | None = None
82
+ while True:
83
+ page = await client.list_tools(cursor=cursor)
84
+ tools += [t.model_dump(by_alias=True, exclude_none=True) for t in page.tools]
85
+ cursor = page.next_cursor
86
+ if not cursor:
87
+ return tools
88
+
89
+
90
+ async def fetch_one(spec: ServerSpec, timeout: float = 20) -> ServerResult:
91
+ """Fetch one server's tools. Never raises: failures become ``status="error"``."""
92
+ statuses: list[int] = []
93
+ with tempfile.TemporaryFile("w+", encoding="utf-8", errors="replace") as errlog:
94
+ try:
95
+ return ServerResult(spec, "ok", await _list_tools(spec, timeout, statuses, errlog))
96
+ except TimeoutError:
97
+ error = f"timed out after {timeout:g}s"
98
+ except Exception as e:
99
+ error = _describe(e, statuses)
100
+ errlog.seek(0)
101
+ stderr = [line.strip() for line in errlog.read().splitlines() if line.strip()]
102
+ if stderr: # a server that exits early usually says why on stderr
103
+ error += f"; server said: {stderr[-1][:200]}"
104
+ return ServerResult(spec, "error", error=error)
105
+
106
+
107
+ def fetch_all(
108
+ specs: list[ServerSpec], *, timeout: float = 20, concurrency: int = 4
109
+ ) -> list[ServerResult]:
110
+ """Fetch every server, launching each distinct command/URL once. Order matches ``specs``."""
111
+ unique: dict[tuple[Any, ...], ServerSpec] = {}
112
+ for s in specs:
113
+ unique.setdefault(_key(s), s)
114
+ done: dict[tuple[Any, ...], ServerResult] = {}
115
+
116
+ async def run() -> None:
117
+ limiter = anyio.CapacityLimiter(concurrency)
118
+
119
+ async def one(key: tuple[Any, ...], spec: ServerSpec) -> None:
120
+ async with limiter:
121
+ done[key] = await fetch_one(spec, timeout)
122
+
123
+ async with anyio.create_task_group() as tg:
124
+ for key, spec in unique.items():
125
+ tg.start_soon(one, key, spec)
126
+
127
+ anyio.run(run)
128
+ results = []
129
+ for s in specs:
130
+ shared = done[_key(s)]
131
+ results.append(ServerResult(s, shared.status, shared.tools, shared.error))
132
+ return results
upfront/py.typed ADDED
File without changes
upfront/report.py ADDED
@@ -0,0 +1,157 @@
1
+ """Assemble discovery, fetch, token counts and compat findings into one report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from dataclasses import asdict, dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from upfront import compat, tokens
12
+ from upfront.discover import HARNESSES, Discovery, Harness, ServerSpec, discover
13
+ from upfront.fetch import ServerResult, Status, fetch_all
14
+
15
+
16
+ @dataclass
17
+ class ToolUsage:
18
+ name: str
19
+ tokens: int
20
+
21
+
22
+ @dataclass
23
+ class ServerUsage:
24
+ name: str
25
+ scope: str
26
+ transport: str
27
+ target: str
28
+ source: str
29
+ status: Status
30
+ error: str | None = None
31
+ tools: list[ToolUsage] = field(default_factory=list)
32
+
33
+ @property
34
+ def tokens(self) -> int:
35
+ return sum(t.tokens for t in self.tools)
36
+
37
+
38
+ @dataclass
39
+ class FileUsage:
40
+ path: str
41
+ tokens: int
42
+
43
+
44
+ @dataclass
45
+ class HarnessReport:
46
+ harness: Harness
47
+ servers: list[ServerUsage] = field(default_factory=list)
48
+ skills_count: int = 0
49
+ skills_metadata_tokens: int = 0
50
+ skills_body_tokens: int = 0
51
+ instructions: list[FileUsage] = field(default_factory=list)
52
+ findings: list[compat.Finding] = field(default_factory=list)
53
+ errors: list[str] = field(default_factory=list)
54
+
55
+ @property
56
+ def total(self) -> int:
57
+ """Tokens loaded before the first prompt: tools + skill metadata + instruction files."""
58
+ servers = sum(s.tokens for s in self.servers if s.status == "ok")
59
+ return servers + self.skills_metadata_tokens + sum(f.tokens for f in self.instructions)
60
+
61
+ @property
62
+ def empty(self) -> bool:
63
+ return not (self.servers or self.skills_count or self.instructions or self.errors)
64
+
65
+
66
+ @dataclass
67
+ class Report:
68
+ root: str
69
+ tokenizer: str
70
+ harnesses: list[HarnessReport]
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ def harness(h: HarnessReport) -> dict[str, Any]:
74
+ d = asdict(h)
75
+ d["total"] = h.total
76
+ for s, raw in zip(h.servers, d["servers"], strict=True):
77
+ raw["tokens"] = s.tokens
78
+ return d
79
+
80
+ return {
81
+ "root": self.root,
82
+ "tokenizer": self.tokenizer,
83
+ "harnesses": [harness(h) for h in self.harnesses],
84
+ }
85
+
86
+ def to_json(self) -> str:
87
+ return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)
88
+
89
+
90
+ def _server_usage(result: ServerResult, harness: str) -> tuple[ServerUsage, list[compat.Finding]]:
91
+ spec = result.spec
92
+ usage = ServerUsage(
93
+ spec.name, spec.scope, spec.transport, spec.target, str(spec.source),
94
+ result.status, result.error,
95
+ sorted((ToolUsage(str(t.get("name", "")), tokens.tool_tokens(t)) for t in result.tools),
96
+ key=lambda t: -t.tokens),
97
+ ) # fmt: skip
98
+ findings = compat.check_tools(harness, spec.name, result.tools) if result.status == "ok" else []
99
+ return usage, findings
100
+
101
+
102
+ def build_report(discovery: Discovery, results: Iterable[ServerResult]) -> Report:
103
+ by_spec = {id(r.spec): r for r in results}
104
+ out: list[HarnessReport] = []
105
+ for name, ctx in discovery.harnesses.items():
106
+ h = HarnessReport(name, errors=list(ctx.errors))
107
+ for spec in ctx.servers:
108
+ r = by_spec.get(id(spec)) or ServerResult(spec, "skipped")
109
+ usage, findings = _server_usage(r, name)
110
+ h.servers.append(usage)
111
+ h.findings += findings
112
+ h.servers.sort(key=lambda s: -s.tokens)
113
+ h.skills_count = len(ctx.skills)
114
+ for s in ctx.skills:
115
+ meta, body = tokens.skill_tokens(s)
116
+ h.skills_metadata_tokens += meta
117
+ h.skills_body_tokens += body
118
+ h.findings += compat.check_skills(name, ctx.skills)
119
+ h.instructions = [FileUsage(str(f.path), tokens.count(f.text)) for f in ctx.instructions]
120
+ out.append(h)
121
+ return Report(str(discovery.root), tokens.tokenizer_name(), out)
122
+
123
+
124
+ def servers_to_launch(discovery: Discovery) -> list[ServerSpec]:
125
+ return [s for ctx in discovery.harnesses.values() for s in ctx.servers]
126
+
127
+
128
+ def profile(
129
+ root: Path | str = ".",
130
+ *,
131
+ home: Path | None = None,
132
+ launch: bool = False,
133
+ only: Iterable[str] | None = None,
134
+ timeout: float = 20,
135
+ ) -> Report:
136
+ """Library entry point: discover configs under ``root`` and measure them.
137
+
138
+ ``launch=False`` (the default) never starts a process or opens a connection; servers are
139
+ reported as ``skipped``. See ADR-0004.
140
+ """
141
+ d = discover(Path(root), home=home, only=only)
142
+ results = fetch_all(servers_to_launch(d), timeout=timeout) if launch else []
143
+ return build_report(d, results)
144
+
145
+
146
+ def profile_tools_file(path: Path, *, only: Iterable[str] | None = None) -> Report:
147
+ """Measure a saved ``tools/list`` JSON (``{"tools": [...]}`` or a list) for each client."""
148
+ data = json.loads(path.read_text(encoding="utf-8"))
149
+ tools = data["tools"] if isinstance(data, dict) else data
150
+ wanted = [h for h in HARNESSES if only is None or h in set(only)]
151
+ name = path.name.split(".")[0] # "github.tools.json" -> "github"
152
+ out = []
153
+ for harness in wanted:
154
+ spec = ServerSpec(harness, name, "project", path, "stdio", command="(tools file)")
155
+ usage, findings = _server_usage(ServerResult(spec, "ok", tools), harness)
156
+ out.append(HarnessReport(harness, servers=[usage], findings=findings))
157
+ return Report(str(path), tokens.tokenizer_name(), out)
upfront/tokens.py ADDED
@@ -0,0 +1,50 @@
1
+ """Token estimates for what a client sends: tool definitions, skill metadata, instruction files.
2
+
3
+ One tokenizer (``o200k_base``) is used for every harness; numbers are estimates (ADR-0003).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import math
10
+ from functools import lru_cache
11
+ from typing import Any
12
+
13
+ import tiktoken
14
+
15
+ from upfront.discover import Skill
16
+
17
+ ENCODING = "o200k_base"
18
+
19
+ # Fields of an MCP Tool that clients forward to the model. `_meta` and `icons` are client-side.
20
+ WIRE_FIELDS = ("name", "title", "description", "inputSchema", "outputSchema", "annotations")
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def _encoder() -> tiktoken.Encoding | None:
25
+ try:
26
+ return tiktoken.get_encoding(ENCODING)
27
+ except Exception: # offline, blocked CDN, corrupt cache: fall back to an estimate
28
+ return None
29
+
30
+
31
+ def tokenizer_name() -> str:
32
+ return ENCODING if _encoder() is not None else "chars/4 estimate (tokenizer unavailable)"
33
+
34
+
35
+ def count(text: str) -> int:
36
+ enc = _encoder()
37
+ if enc is None:
38
+ return math.ceil(len(text) / 4)
39
+ return len(enc.encode(text, disallowed_special=()))
40
+
41
+
42
+ def tool_tokens(tool: dict[str, Any]) -> int:
43
+ """Tokens for one tool definition as compact JSON of its wire fields."""
44
+ wire = {k: tool[k] for k in WIRE_FIELDS if k in tool}
45
+ return count(json.dumps(wire, separators=(",", ":"), ensure_ascii=False))
46
+
47
+
48
+ def skill_tokens(skill: Skill) -> tuple[int, int]:
49
+ """(metadata loaded up front, body loaded when the skill is used)."""
50
+ return count(f"{skill.name}: {skill.description}"), count(skill.body)
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.5
2
+ Name: upfront
3
+ Version: 0.1.0
4
+ Summary: See how many tokens your MCP servers, skills and instruction files cost a coding agent before your first prompt, and which tools break in which client.
5
+ Project-URL: Homepage, https://github.com/thanatchon36/upfront
6
+ Project-URL: Issues, https://github.com/thanatchon36/upfront/issues
7
+ Project-URL: Changelog, https://github.com/thanatchon36/upfront/blob/main/CHANGELOG.md
8
+ Author: Thanatchon
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude-code,codex,context-window,gemini-cli,llm,mcp,model-context-protocol,opencode,tokens
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: mcp>=2.3.0
22
+ Requires-Dist: tiktoken>=0.14.0
23
+ Requires-Dist: typer>=0.27.3
24
+ Description-Content-Type: text/markdown
25
+
26
+ # upfront
27
+
28
+ [![ci](https://github.com/thanatchon36/upfront/actions/workflows/ci.yml/badge.svg)](https://github.com/thanatchon36/upfront/actions/workflows/ci.yml)
29
+ [![coverage](https://codecov.io/gh/thanatchon36/upfront/graph/badge.svg)](https://codecov.io/gh/thanatchon36/upfront)
30
+ [![PyPI](https://img.shields.io/pypi/v/upfront)](https://pypi.org/project/upfront/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/upfront)](https://pypi.org/project/upfront/)
32
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/thanatchon36/upfront/blob/main/LICENSE)
33
+
34
+ See how many tokens your MCP servers, skills and instruction files cost a coding agent before
35
+ you type a word, and which tools will break in Claude Code, Codex, Gemini CLI or OpenCode.
36
+
37
+ ## The problem
38
+
39
+ > "Claude Code sends 33k tokens before reading the prompt; OpenCode sends 7k"
40
+ > — [Hacker News, 706 points](https://news.ycombinator.com/item?id=48883275); finding out took a logging proxy.
41
+
42
+ Every connected MCP server puts all of its tool schemas into the context, every skill adds its
43
+ description, and AGENTS.md / CLAUDE.md ride along on every request. Usage limits arrive early,
44
+ and Codex starts dropping skills ([codex#19679](https://github.com/openai/codex/issues/19679))
45
+ and MCP tools ([codex#44308](https://github.com/openai/codex/issues/44308)) once budgets run
46
+ out. Claude Code's `/context` shows this for Claude Code only; OpenCode closed the request as
47
+ [not planned](https://github.com/anomalyco/opencode/issues/11995).
48
+
49
+ ## Demo
50
+
51
+ ![upfront measuring a demo project with three official MCP servers](https://raw.githubusercontent.com/thanatchon36/upfront/main/docs/demo.gif)
52
+
53
+ ```console
54
+ $ uv run upfront examples/demo --yes --top 2 # in a clone of this repository
55
+ upfront 0.1.0 · examples/demo · tokens o200k_base
56
+
57
+ claude-code ≈ 6,786 before your first prompt
58
+ mcp filesystem (14 tools, project) ≈ 2,751
59
+ read_media_file ≈ 285
60
+ read_text_file ≈ 251
61
+ mcp memory (9 tools, project) ≈ 2,365
62
+ create_entities ≈ 349
63
+ search_nodes ≈ 320
64
+ mcp everything (13 tools, project) ≈ 1,602
65
+ gzip-file-as-resource ≈ 239
66
+ get-structured-content ≈ 186
67
+ skills (2, name + description) ≈ 33 bodies ≈ 48 load on demand
68
+ CLAUDE.md ≈ 35
69
+ ...
70
+ compatibility
71
+ error codex issue-scenarios.files.read: 'files.read' (10 chars) is not a valid OpenAI function name
72
+ [openai-tool-name] https://github.com/openai/openai-python/blob/main/src/openai/types/shared_params/function_definition.py
73
+ ```
74
+
75
+ The demo's servers replay `tools/list` snapshots of the official
76
+ [filesystem, memory and everything](https://github.com/modelcontextprotocol/servers) reference
77
+ servers (1.0.0), so the numbers above are what those servers really cost.
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ uvx --from git+https://github.com/thanatchon36/upfront upfront # run from your project, no install
83
+ pip install upfront # once v0.1.0 is on PyPI
84
+ ```
85
+
86
+ Python 3.11+. Then, in a project:
87
+
88
+ ```bash
89
+ upfront # skills, instruction files and the list of servers; starts nothing
90
+ upfront --yes # also start/connect to each MCP server and count its tools
91
+ ```
92
+
93
+ ## Features
94
+
95
+ - **Four harnesses, both scopes.** Reads Claude Code, Codex, Gemini CLI and OpenCode configs from the project and your home directory, including `${VAR:-default}` expansion and JSONC.
96
+ - **Real tool lists.** Talks to every stdio, Streamable HTTP and SSE server with the official MCP SDK, follows pagination, and reports failures (missing command, timeout, "auth required") per server instead of crashing.
97
+ - **Where the tokens go.** Per server and per tool, skill metadata separately from skill bodies, and every instruction file.
98
+ - **Compatibility checks with sources.** Tool names against OpenAI, Anthropic and Gemini rules, `$ref` parameters in Claude Code, non-standard formats in OpenCode, and Codex's skills budget. Every finding links the documentation or open issue it comes from.
99
+ - **CI-friendly.** `--json`, `--budget N` and `--strict` exit codes, and `--tools-file` to check a saved `tools/list` without starting anything.
100
+ - **Safe by default.** Nothing is launched without consent: it asks in a terminal and needs `--yes` elsewhere. It only ever calls `tools/list`.
101
+
102
+ ## How it compares
103
+
104
+ | | upfront | Claude Code [`/context`](https://code.claude.com/docs/en/commands) | [mcp-assert](https://github.com/blackwell-systems/mcp-assert) | [ccusage](https://github.com/ccusage/ccusage) |
105
+ |---|---|---|---|---|
106
+ | Up-front cost of MCP tools, skills, instruction files | yes, per harness | yes, Claude Code only | no | no |
107
+ | Works for Codex, Gemini CLI, OpenCode | yes | no | n/a | n/a |
108
+ | Per-client schema compatibility | yes, 6 sourced rules | no | no; 24 usability lint rules (descriptions, nesting) | no |
109
+ | Tests tool *behaviour* | no | no | yes (assertions, fuzzing) | no |
110
+ | Actual spend from past sessions | no | `/usage` | no | yes |
111
+
112
+ Use `/context` inside Claude Code, mcp-assert to test what your tools do, and
113
+ ccusage to see what you've already spent. upfront answers "what will each agent load, and what
114
+ breaks where" before you start.
115
+
116
+ ## How it works
117
+
118
+ 1. **Discover**: parse each harness's documented config files ([concepts](https://github.com/thanatchon36/upfront/blob/main/docs/concepts.md#where-upfront-looks)).
119
+ 2. **Fetch**: with consent, start each distinct server once (from the project directory, like the agent) and page through `tools/list`.
120
+ 3. **Count**: tokenize each tool's wire JSON with `o200k_base`; skill metadata and bodies separately; instruction files whole.
121
+ 4. **Check**: run the per-client rules ([table](https://github.com/thanatchon36/upfront/blob/main/docs/api.md#compatibility-rules)).
122
+
123
+ More: [quickstart](https://github.com/thanatchon36/upfront/blob/main/docs/quickstart.md) · [concepts](https://github.com/thanatchon36/upfront/blob/main/docs/concepts.md) · [FAQ](https://github.com/thanatchon36/upfront/blob/main/docs/faq.md) · [API](https://github.com/thanatchon36/upfront/blob/main/docs/api.md) · [decisions](https://github.com/thanatchon36/upfront/blob/main/docs/decisions/)
124
+
125
+ ## Limitations
126
+
127
+ - **Estimates.** One tokenizer for every model; vendors' counts differ, and clients add their own framing around tools ([ADR-0003](https://github.com/thanatchon36/upfront/blob/main/docs/decisions/ADR-0003-tokenizer.md)).
128
+ - **Not the vendor's system prompt.** Only what you add is measured.
129
+ - **Lazy loading isn't modelled.** Claude Code's tool search and OpenCode's lazy MCP loading defer some definitions; upfront reports their full cost.
130
+ - **No OAuth.** Remote servers that need a login show "auth required; not measured". Headers from your config are sent; OAuth flows are not run.
131
+ - **Root-level instruction files only**, and only the four CLI harnesses above (no Cursor or Windsurf yet).
132
+ - **Codex's 2% skills budget** depends on the model's window; upfront checks the documented 8,000-character fallback.
133
+
134
+ ## Roadmap
135
+
136
+ - **v0.2**: exact counts via vendor token-count APIs (opt-in), a `--fix` proxy that rewrites schemas per client, Cursor and Windsurf configs.
137
+ - **v0.3**: `upfront diff` between git refs for MCP server pull requests, and a GitHub Action.
138
+
139
+ ## Contributing
140
+
141
+ Bug reports with a config that reproduces the problem help most. See
142
+ [CONTRIBUTING.md](https://github.com/thanatchon36/upfront/blob/main/CONTRIBUTING.md); compatibility rules need a public source.
143
+
144
+ ## License
145
+
146
+ [MIT](https://github.com/thanatchon36/upfront/blob/main/LICENSE)
@@ -0,0 +1,13 @@
1
+ upfront/__init__.py,sha256=DIdriHrfoums1yKsz8wlZ4JzbH84o4ydCZGHuymxB8g,231
2
+ upfront/cli.py,sha256=Q5Fr2Q8OYp0pMSBIZyYJu4nQFEY5MY6PmY_BXLqWRBQ,5829
3
+ upfront/compat.py,sha256=e-NsALUMWWmPgPIQpfZ9lLjtVM8A8yZdwIIxB3ZNSIQ,7144
4
+ upfront/discover.py,sha256=qLiNWhyKyxHkvmNxHHmIrOSinCqm_AEYMmY69RMjVOU,15614
5
+ upfront/fetch.py,sha256=Zfof2euItVMzOyMyrvce60yNsaKf8tz268yaJeSh6Q8,4738
6
+ upfront/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ upfront/report.py,sha256=yMLd8ezWFvl_dJQiXSUq4bg75AiPtAz6VQEC_-Ukw0M,5416
8
+ upfront/tokens.py,sha256=Z9yVgrxrmSiIN7s-e7eGntkcjm66Dch8AgBdc8_m4Io,1564
9
+ upfront-0.1.0.dist-info/METADATA,sha256=GgZOw16IuJ7myNoTBnJzzL3Bd_z5xqFYSdPm-bKA79M,8937
10
+ upfront-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ upfront-0.1.0.dist-info/entry_points.txt,sha256=qK-OiuI2cduITedrQwzCMCxrfCtGatZDV-hYXLsFX6I,44
12
+ upfront-0.1.0.dist-info/licenses/LICENSE,sha256=Jtf260CBjZOjOV_B54IS4Gyo0G-FpRGipO7oXBPfqYA,1067
13
+ upfront-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ upfront = upfront.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thanatchon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.