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.
- tracker_mcp/__init__.py +8 -0
- tracker_mcp/config.py +150 -0
- tracker_mcp/example.tracker-mcp.toml +98 -0
- tracker_mcp/gsheets.py +567 -0
- tracker_mcp/scaffold.py +632 -0
- tracker_mcp/server.py +638 -0
- tracker_mcp/stores.py +288 -0
- tracker_mcp/tracker.py +995 -0
- tracker_mcp-0.3.0.dist-info/METADATA +363 -0
- tracker_mcp-0.3.0.dist-info/RECORD +13 -0
- tracker_mcp-0.3.0.dist-info/WHEEL +4 -0
- tracker_mcp-0.3.0.dist-info/entry_points.txt +2 -0
- tracker_mcp-0.3.0.dist-info/licenses/LICENSE +21 -0
tracker_mcp/__init__.py
ADDED
|
@@ -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
|