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 +7 -0
- upfront/cli.py +147 -0
- upfront/compat.py +227 -0
- upfront/discover.py +439 -0
- upfront/fetch.py +132 -0
- upfront/py.typed +0 -0
- upfront/report.py +157 -0
- upfront/tokens.py +50 -0
- upfront-0.1.0.dist-info/METADATA +146 -0
- upfront-0.1.0.dist-info/RECORD +13 -0
- upfront-0.1.0.dist-info/WHEEL +4 -0
- upfront-0.1.0.dist-info/entry_points.txt +2 -0
- upfront-0.1.0.dist-info/licenses/LICENSE +21 -0
upfront/__init__.py
ADDED
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
|
+
[](https://github.com/thanatchon36/upfront/actions/workflows/ci.yml)
|
|
29
|
+
[](https://codecov.io/gh/thanatchon36/upfront)
|
|
30
|
+
[](https://pypi.org/project/upfront/)
|
|
31
|
+
[](https://pypi.org/project/upfront/)
|
|
32
|
+
[](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
|
+

|
|
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,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.
|