tracker-mcp 0.3.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.
@@ -0,0 +1,8 @@
1
+ """MCP server over a job-application tracker: Excel, CSV or Google Sheets."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("tracker-mcp")
7
+ except PackageNotFoundError: # running from a source tree without installing
8
+ __version__ = "0.0.0"
tracker_mcp/config.py ADDED
@@ -0,0 +1,150 @@
1
+ """What the server needs to know about a particular tracker workbook.
2
+
3
+ Nothing about one person's tracker lives in the code: the sheet, the column
4
+ headers, the status words, the follow-up rule and the texts the server writes
5
+ into the follow-up log all come from a TOML file. By default the file sits next
6
+ to the workbook: `my-tracker.xlsx` -> `my-tracker.tracker-mcp.toml`.
7
+ `TRACKER_CONFIG` points to another one. `examples/tracker-mcp.example.toml`
8
+ describes every key.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import tomllib
14
+ from pathlib import Path
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
17
+
18
+ # Fields the server's logic understands. Any other key in [columns] is a custom
19
+ # field: it is returned under its own key and can be set through add_application.
20
+ REQUIRED_FIELDS = ("company", "role", "status")
21
+ KNOWN_FIELDS = REQUIRED_FIELDS + (
22
+ "num", "link", "applied", "applied_on", "channel", "response", "notes",
23
+ "followup_on", "followup_contact", "followup_log", "reached",
24
+ )
25
+
26
+
27
+ class _Strict(BaseModel):
28
+ model_config = ConfigDict(extra="forbid", frozen=True)
29
+
30
+
31
+ class Statuses(_Strict):
32
+ not_applied: str = "Not applied"
33
+ applied: str = "Applied"
34
+ closed: list[str] = ["Rejected", "Withdrawn", "No response", "Offer", "Closed"]
35
+ # Still waiting for the company: close_stale only touches these.
36
+ waiting: list[str] | None = None # default: [applied]
37
+ no_response: str = "No response" # what close_stale sets
38
+ # For the funnel. A rejection is a reply. Stages run in this order, and a status
39
+ # in a later stage means the application got through the earlier ones.
40
+ rejected: list[str] = ["Rejected"]
41
+ stages: dict[str, list[str]] = {"screening": ["Screening"], "interview": ["Interview"], "offer": ["Offer"]}
42
+ # Used only when the status column has no dropdown in the workbook.
43
+ fallback: list[str] = ["Not applied", "Applied", "Screening", "Interview", "Offer",
44
+ "Rejected", "Withdrawn", "No response", "Closed"]
45
+ # Alternative spellings the model may use -> the value from the dropdown.
46
+ aliases: dict[str, str] = {}
47
+
48
+
49
+ class Values(_Strict):
50
+ yes: str = "Yes" # «applied» column, when there is one
51
+ no: str = "No"
52
+ no_date: str = "—" # written into the follow-up date when none is due
53
+ separator: str = " | " # between entries appended to a text cell
54
+ stamp_format: str = "%Y-%m-%d" # date prefix of appended entries (strftime)
55
+ # Extra formats for dates kept as text, tried after ISO and dd.mm.yyyy,
56
+ # e.g. ["%m/%d/%Y"] for US dates or ["%d/%m/%Y"] for UK ones. `init` detects them.
57
+ date_formats: list[str] = []
58
+
59
+
60
+ class Followups(_Strict):
61
+ first_after_days: int = Field(10, ge=0) # submitted -> follow-up 1
62
+ next_after_days: int = Field(14, ge=0) # follow-up sent -> the next one
63
+ weekend_to_monday: bool = True
64
+
65
+
66
+ class Messages(_Strict):
67
+ """Texts appended to the follow-up log. {date} and {status} are filled in."""
68
+ applied: str = "applied"
69
+ applied_with_followup: str = "applied, follow-up 1 on {date}"
70
+ followup_cancelled: str = "follow-up {date} cancelled: {status}"
71
+ stale_closed: str = "closed: no reply for {days} days"
72
+ followup_moved: str = "follow-up moved to {date}: {reason}"
73
+ followup_dropped: str = "no follow-up planned: {reason}"
74
+
75
+
76
+ class Funnel(_Strict):
77
+ # group name -> substrings (any case). A value of the grouped column goes to the
78
+ # first group with a matching substring, otherwise to its own text up to the
79
+ # first bracket or dash: "Ashby (direct form)" -> "Ashby".
80
+ groups: dict[str, list[str]] = {}
81
+ # The same, for one column only: [funnel.by.source] "LinkedIn" = ["linkedin"].
82
+ # Used instead of `groups` when grouping by that column.
83
+ by: dict[str, dict[str, list[str]]] = {}
84
+
85
+
86
+ class Safety(_Strict):
87
+ # Every write needs the user's yes: through the client's confirmation dialog
88
+ # (MCP elicitation) when it has one, otherwise through the token from a dry run.
89
+ confirm_writes: bool = True
90
+
91
+
92
+ class Config(_Strict):
93
+ sheet: str
94
+ summary_sheet: str | None = None # its formulas are stretched when rows are added
95
+ header_row: int | None = Field(None, ge=1) # found automatically if not set
96
+ # field -> header text. Exact match wins, otherwise the first header
97
+ # (left to right) that starts with this text.
98
+ columns: dict[str, str]
99
+ date_columns: list[str] = ["applied_on", "followup_on"]
100
+ statuses: Statuses = Statuses()
101
+ values: Values = Values()
102
+ followups: Followups = Followups()
103
+ messages: Messages = Messages()
104
+ funnel: Funnel = Funnel()
105
+ safety: Safety = Safety()
106
+
107
+ @field_validator("columns")
108
+ @classmethod
109
+ def _required_columns(cls, cols: dict[str, str]) -> dict[str, str]:
110
+ missing = [f for f in REQUIRED_FIELDS if f not in cols]
111
+ if missing:
112
+ raise ValueError(f"[columns] must define {', '.join(missing)}")
113
+ return cols
114
+
115
+ def has(self, field: str) -> bool:
116
+ return field in self.columns
117
+
118
+ def custom_fields(self) -> list[str]:
119
+ return [f for f in self.columns if f not in KNOWN_FIELDS]
120
+
121
+
122
+ class ConfigError(Exception):
123
+ pass
124
+
125
+
126
+ def config_path(tracker: str | Path, explicit: str | Path | None = None) -> Path:
127
+ """The config file for a tracker: `explicit`, else next to the file
128
+ (my-tracker.xlsx -> my-tracker.tracker-mcp.toml), else for a Google Sheet
129
+ ~/.tracker-mcp/<sheet id>.tracker-mcp.toml."""
130
+ if explicit:
131
+ return Path(explicit).expanduser()
132
+ t = str(tracker)
133
+ if t.startswith(("https://docs.google.com/spreadsheets/", "gsheet:")):
134
+ from .gsheets import default_config_path
135
+ return default_config_path(t)
136
+ path = Path(t).expanduser()
137
+ return path.with_name(f"{path.stem}.tracker-mcp.toml")
138
+
139
+
140
+ def load_config(tracker: str | Path, explicit: str | Path | None = None) -> Config:
141
+ path = config_path(tracker, explicit)
142
+ if not path.exists():
143
+ raise ConfigError(f"No config at {path}. Run `tracker-mcp init {tracker}` to write one from "
144
+ "your tracker's headers, or `tracker-mcp example` for a commented template.")
145
+ try:
146
+ return Config.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
147
+ except tomllib.TOMLDecodeError as e:
148
+ raise ConfigError(f"{path}: not valid TOML: {e}") from e
149
+ except ValidationError as e:
150
+ raise ConfigError(f"{path}: {e}") from e
@@ -0,0 +1,98 @@
1
+ # tracker-mcp config. You rarely write it by hand: on first start the server, or
2
+ # `tracker-mcp init <tracker>`, writes one from your headers and dropdowns and marks
3
+ # its guesses with CHECK. This file explains every key.
4
+ #
5
+ # Where it lives: next to the tracker as <name>.tracker-mcp.toml
6
+ # (my-tracker.xlsx or my-tracker.csv -> my-tracker.tracker-mcp.toml). For a Google
7
+ # Sheet: ~/.tracker-mcp/<sheet id>.tracker-mcp.toml. TRACKER_CONFIG points elsewhere.
8
+ # Check how it maps your tracker: tracker-mcp --check --tracker /path/my-tracker.xlsx
9
+ # Edits apply on the server's next call; no restart needed.
10
+
11
+ sheet ="Applications" # the sheet with one row per application (a CSV file has one: its name)
12
+ # summary_sheet = "Summary" # optional: its formulas are stretched when rows are added
13
+ # header_row = 1 # optional: found automatically (the row with the company header)
14
+ # Columns that hold dates (read in any common format, written as yyyy-mm-dd).
15
+ # Top-level keys go before the first [table]: below [columns] this would become a column.
16
+ date_columns = ["applied_on", "followup_on"]
17
+
18
+ # field = header text in the header row. Exact match wins, otherwise the first
19
+ # header (left to right) that starts with this text, so "Follow-up" matches
20
+ # "Follow-up date (1st)". company, role and status are required; the server
21
+ # skips what a missing column would need (no follow-up column -> no follow-up tools).
22
+ [columns]
23
+ company = "Company"
24
+ role = "Role"
25
+ status = "Status"
26
+ link = "Link"
27
+ applied_on = "Applied on"
28
+ channel = "Channel"
29
+ response = "Response"
30
+ notes = "Notes"
31
+ followup_on = "Follow-up date"
32
+ followup_contact = "Contact"
33
+ followup_log = "Follow-up log"
34
+ # num = "#" # running number, filled in for new rows
35
+ # applied = "Applied?" # a yes/no column; without it "applied" means status is not not_applied
36
+ # reached = "Furthest stage" # the furthest funnel stage; set_status moves it forward, never back
37
+ # Any other key is a custom field, returned as is and settable via add_application's `extra`:
38
+ # salary = "Salary"
39
+
40
+ [statuses]
41
+ # Allowed statuses come from the status column's dropdown (Excel data validation,
42
+ # or a Google Sheets dropdown). fallback is used when there is none, as in a CSV file.
43
+ not_applied = "Not applied"
44
+ applied = "Applied"
45
+ closed = ["Rejected", "Withdrawn", "No response", "Offer", "Closed"] # no follow-ups for these
46
+ fallback = ["Not applied", "Applied", "Screening", "Interview", "Offer", "Rejected", "Withdrawn", "No response", "Closed"]
47
+ aliases = { "ghosted" = "No response" } # other spellings -> the dropdown value
48
+ waiting = ["Applied"] # still waiting for the company; close_stale only touches these
49
+ no_response = "No response" # the status close_stale sets
50
+ rejected = ["Rejected"] # the company said no: counts as a reply in the funnel
51
+
52
+ # Funnel stages after a reply, in order. A status in a later stage means the
53
+ # application got through the earlier ones.
54
+ [statuses.stages]
55
+ screening = ["Screening"]
56
+ interview = ["Interview", "Technical", "Final"]
57
+ offer = ["Offer"]
58
+
59
+ [values]
60
+ yes = "Yes"
61
+ no = "No"
62
+ no_date = "—" # follow-up date when none is due
63
+ separator = " | " # between entries appended to a text cell
64
+ stamp_format = "%Y-%m-%d" # date prefix of appended entries and {date} in messages
65
+ # Dates kept as text are read as ISO (2026-10-19) and dd.mm.yyyy; list other formats
66
+ # here, tried in order. `init` detects them. US: ["%m/%d/%Y"]; UK: ["%d/%m/%Y"].
67
+ date_formats = []
68
+
69
+ [followups]
70
+ first_after_days = 10 # applied -> follow-up 1
71
+ next_after_days = 14 # follow-up sent -> the next one
72
+ weekend_to_monday = true
73
+
74
+ [messages] # appended to the follow-up log
75
+ applied = "applied"
76
+ applied_with_followup = "applied, follow-up 1 on {date}"
77
+ followup_cancelled = "follow-up {date} cancelled: {status}"
78
+ stale_closed = "closed: no reply for {days} days"
79
+ followup_moved = "follow-up moved to {date}: {reason}" # reschedule_followup
80
+ followup_dropped = "no follow-up planned: {reason}"
81
+
82
+ [funnel.groups]
83
+ # For `funnel(by=...)` over a free-text column. A value goes to the first group
84
+ # with a matching substring (any case), otherwise to its text up to the first
85
+ # bracket or dash: "Ashby (direct form)" -> "Ashby".
86
+ "Referral" = ["referral", "referred", "via a friend"]
87
+ "Recruiter" = ["recruiter", "agency"]
88
+
89
+ [funnel.by.source]
90
+ # Groups for one column only, used instead of the ones above when grouping by it.
91
+ "LinkedIn" = ["linkedin"]
92
+ "Job boards" = ["hiring.cafe", "himalayas", "wellfound", "remote"]
93
+ "Referral" = ["referral", "referred"]
94
+
95
+ [safety]
96
+ # Every write needs the user's yes: the client's confirmation dialog (MCP elicitation)
97
+ # when it has one, otherwise the confirm token from a dry run the user saw.
98
+ confirm_writes = true