
tectonic plates
app.tectonicplates由 tectonicplates.app 發布官方發布者v0.1.0更新於 Oct 24, 2026
Write whole barbell programmes, not just workouts: weeks, progressions, ramps, and plate maths.
概覽
Tectonic 是一款槓鈴肌力訓練紀錄工具,其遠端 MCP 端點讓助理記錄訓練、讀取訓練歷史並撰寫多週訓練計畫。
- 可以做什麼
- 記錄訓練:建立動作、訓練與組,完成或修改組,刪除組,為訓練評分
- 回讀訓練:列出動作與訓練,取得單次訓練,查看某個動作的歷史
- 規劃訓練:一次建立整個計畫區塊,新增週、訓練日與動作,修改或刪除動作,把某一週產生為真實訓練
- 目標與身體資料:設定目標、查看區塊進度,讀取體重計資料、體重趨勢與身體組成
- 連接器介面:依動作、訓練或計畫代號進行 search 與 fetch,以及查詢目前帳號的 whoami
- 適用情境
- 適合在 Tectonic 中記錄槓鈴訓練的人,讓助理代為記錄訓練、回顧歷史,或起草並產生含熱身遞增與槓片計算的數週訓練計畫,而不只是記錄單次訓練。
- 執行需求
- 需要服務商主機上 /mcp 路徑的遠端 streamable HTTP 端點;必須使用 OAuth 2.1 授權,用戶端自行註冊並由使用者在同意頁面核准,無需貼上權杖。無介面呼叫端需註冊機密用戶端並使用用戶端憑證授權。清單未宣告用戶端需要任何環境變數或請求標頭。
安裝
在 SourceWeft 中
- 開啟 儀表板中的 tectonic plates,將其新增到工作區。
- 連接 所需的 憑證。它們會被私密儲存,絕不會在公開頁面上展示。
- 為需要使用其工具的對話啟用該服務。
Web executable,透過 Streamable HTTP。 遠端服務在工作區中設定後即可從網頁執行環境執行。
交給代理程式安裝
把這段提示詞貼上到 Claude Code、Codex、Cursor 等代理程式,或 SourceWeft 對話中。代理程式會讀取該伺服器的安裝指南,先向你展示發布者、端點和登入方式,經你同意後再把它新增到你的客戶端。金鑰不會寫進任何命令。
閱讀 https://sourceweft.com/mcp/app.tectonicplates%2Ftectonic-plates/install.md,並按其中說明把這個 MCP 伺服器接入我的客戶端。修改任何設定之前,先告訴我它的發布者、端點和登入方式,等我確認。不要把 API 金鑰寫進命令或我會提交的檔案;問我金鑰應從哪裡取得。其他 MCP 客戶端
把它新增到你客戶端的 mcpServers 設定中。 客戶端會在首次連接時完成 OAuth 登入。
{
"mcpServers": {
"tectonic-plates": {
"type": "http",
"url": "https://tectonicplates.app/mcp"
}
}
}README
Tectonic
Tectonic is a barbell strength-training tracker. An account logs workouts; a workout holds sets of an exercise at a weight and a rep count, in the order they are lifted. All weights are integer pounds.
What makes it more than a list is that a session is written before it is lifted. A program expands into ordinary set rows — warmup ramp included, every weight rounded to something a bar can actually be loaded to — so lifting is tapping through a list that already knows what comes next, and each row keeps the prescription beside what was actually done. An MCP endpoint exposes the same data to an LLM as a connector, which is the second half of this file.
The stack is Roda and Sequel on Postgres, with Rodauth for accounts and rodauth-oauth
for the OAuth 2.1 authorization server the MCP endpoint authenticates against. Views are
ERB with Tailwind from a CDN, and htmx for the parts of the session screen that have to
change without a page load. It deploys to Render.
What it does
- Session view (
/workouts/:id/session) — the gym-floor screen, as distinct from/workouts/:id, which is the record of one. A progress bar across the top, sets grouped by lift with warmups dimmed, the per-side plate breakdown under each weight ("per side 1×25 1×10"), and a Done button per set that toggles, so a mis-tap is undone by tapping again. A set lifted differently is entered inline and the row turns amber rather than lime, because done-as-written and done-differently have to read differently. The whole session gets an RPE rating, with a bar-speed guide for choosing one. - Exercises — 54 built-in barbell movements shared by every account, plus any you add yourself. A built-in exercise is visible to everyone but the sets shown under it are only ever your own.
- Workouts — list, record view, reschedule, delete.
- Programs — the engine below. Authored in Ruby and run from rake; there is no UI for editing one yet.
- Accounts — Rodauth login, account creation, logout, remember-me.
- MCP — audited, per-account tools over an OAuth 2.1-authenticated endpoint at
/mcp.
Getting started
You need Ruby at the version in .ruby-version (the Gemfile reads the same file, so the
two cannot drift), a local Postgres, and — only for the browser specs — Firefox with
geckodriver.
Environment
dotenv loads .env when the app is required, and .env-example is a file to copy:
cp .env-example .env gives a working development setup, pointed at the
tectonic_development database rake db:create makes below. The keys that are secrets in
a deployment are commented out there rather than left blank, each with the command that
generates it, because a blank is worse than an absence here — dotenv sets the name to the
empty string, every fallback in this project tests for nil, and an empty DATABASE_URL
reaches Sequel.connect and raises rather than falling back to a default.
The MCP and OAuth variables are all optional in development and are documented in the table further down.
Database
db:create shells out to createuser -U postgres and createdb -U postgres -O tectonic,
so it assumes a superuser role named postgres and gives the databases to a tectonic
role. If your Postgres has neither, createdb tectonic_development by hand and go
straight to db:migrate, which only needs DATABASE_URL to point somewhere it can
connect.
The schema is one squashed baseline. db:migrate stamps a database that already carries
the tables rather than trying to rebuild or roll it back, so an existing database from
before the squash adopts the baseline instead of breaking on it.
Running it
config.ru mounts two apps side by side under Rack::URLMap: the MCP endpoint at /mcp,
which has its own bearer-token auth and never touches Roda's sessions or CSRF, and the
Roda app at everything else. / redirects to /welcome until you are logged in, so start
by signing up at /create-account.
The stylesheet
assets/css/styles.css is compiled from assets/css/app.css and committed, so nothing
but a stylesheet edit needs Node at all — bundle install and a database are enough to run
the app and the suite.
Rebuild it after adding a Tailwind class that was not already in use. CI rebuilds and fails on a difference, so a stale file is caught before it ships rather than after: a purged class breaks nothing anywhere except on the page it was meant to style, in production, silently.
tailwind.config.js scans app.rb and lib/**/*.rb as well as the templates, because
button_style, rpe_style, row_style and the calendar's STYLES table build class names
in Ruby and appear in no template.
It used to be <script src="https://cdn.tailwindcss.com"> — the Play CDN, which compiles in
the browser. That meant no CSS at all with JavaScript off, and a third party on every page
including the consent screen.
Running the tests
Neither variable has to be passed in. spec_helper sets RACK_ENV and then names the
database itself — TEST_DATABASE_URL when a run wants one of its own, and
postgres:///tectonic_test otherwise — whatever the shell, a .env or the Rakefile had
already chosen. That matters more than it looks: spec_helper empties every table after
every test, so a suite pointed at your development database would not seed it, it would
clear it. The built-in exercise library is the one thing kept, because a deployed database
holds it before anybody signs up. rake 'db:reset[name]' still rebuilds a database whose
schema has gone wrong, but a run no longer leaves rows behind for it to clear away.
Two prerequisites, neither obvious from the failure you get without them:
- Postgres, migrated.
app.rbconnects at require time, so even the pure unit specs need a live database as soon asspec_helperloads the app. - A real Firefox.
spec/exercises_spec.rb,route_ownership_spec.rb,session_spec.rb,system_spec.rbandworkouts_spec.rbdrive a browser through Capybara and selenium, on a Puma server bound to port 9292.MOZ_HEADLESS=1keeps windows from opening; CI sets it for the same reason.
The linters, both of which CI runs — these exact commands, not equivalents of them:
lint:views is a rake task rather than erb_lint with a glob because the glob had to be
expanded by a shell, which reached only one directory deep and was refused outright in
several sandboxed environments. The Rakefile builds the file list in Ruby, so the list is
readable, recursive, and the same one in CI as on your machine.
The program engine
A program is a plan; workouts and sets are what it produces. Four tables hold the plan —
programs, program_weeks, program_days, program_lifts — and generating a week turns
them into ordinary workouts and sets rows with is_completed false. A planned session is not a
separate kind of record: lifting a set as written only flips is_completed, while lifting
it differently changes weight or reps and leaves planned_weight and planned_reps
behind as the record of what was asked for.
programs— the block: a name, astart_date, and two preferences the maths below reads:preferred_reps(the rep count main work should be expressed at) andis_ascending(whether working sets climb to the top weight or sit flat).program_weeks— a numbered week of the block, andis_deload. Dates are derived rather than stored: a week opens seven days after the one before it, counted from the block's start.program_days— a weekday, in Ruby's numbering where 1 is Monday, and a focus such as "Squat". A day is a weekday rather than a date, so the week it belongs to decides when it actually falls.program_lifts— an exercise at a position within the day, withsets,reps,is_barbell(whether it warms up off a bar and shows plate maths),is_main(whether the rep-count preference applies to it) and aprogressionrule with the load it reads:top_weightforfixedandlinear,percent_of_maxforpercent. Position is the order it was written, which is the order it is generated and the order it appears in the session.
Tectonic::ProgramSeed (lib/tectonic/program_seed.rb) is a program written as a frozen
Ruby hash, until there is a UI for editing one. It creates the program, its weeks, days
and lifts, reusing any movement the account can already see — its own or a library one —
and creating only a name nothing answers to. It is idempotent on account, name and block,
so reseeding returns the existing program untouched.
Tectonic::ProgramGenerator (lib/tectonic/program_generator.rb) expands one week of a
program into workouts. It is idempotent on account, program day and date, so regenerating
a week never duplicates sets and never overwrites what was actually lifted.
The maths it generates through is the interesting part of the app, and each piece is specced on its own:
SetScheme(set_scheme.rb) — how many working sets, at what load, for how many reps. It converts the prescription to the program's preferred rep count through the RPE-8 percentage chart, so 4×5 at 155 becomes 4×3 at 165: fewer reps is more weight for the same effort. Conversion only ever goes down, only between rep counts the chart covers, and only for a lift markedis_main, so an accessory prescribed at 8 keeps its 8. Earlier sets then sit 3% below the top per step when the program ascends.Warmup(warmup.rb) — the ramp from the empty 45 lb bar to just under the working weight, in tiers, because a lighter lift needs less of a ramp to get there and reps descend as the weight climbs. Barbell work only: bodyweight, banded and machine lifts ramp differently, if at all. Where rounding would put two ramp steps on the same weight, the repeat is dropped — lifting the same bar twice is not a ramp.Plates(plates.rb) — the per-side breakdown for a loaded bar, searched depth first with the heaviest plate first, so the first exact match is also the one using the fewest plates. It backtracks rather than giving up, so an awkward rack still loads weights a purely greedy walk would call impossible, and returns nil for a weight the rack genuinely cannot make, which the session view renders as nothing at all.Progression(progression.rb) — what the next prescription of a lift should be, read off the last one and what was actually done against it. Alinearlift that was lifted as written gains five pounds; one whose working sets went uncompleted, or came in under the prescription, loses ten; one nobody trained at all repeats. The step down is twice the step up because adding is a guess that costs a session when it is wrong, while subtracting is a response to evidence, and coming back at the weight that just failed wastes the week. A session nobody trained moves nothing in either direction: a missed week is an absence of evidence, and treating it as failure would compound a fortnight away into a load far under what the lifter can do. Ais_deloadweek takes a tenth off whatever the rule arrived at, and is not itself progressed from, so a block resumes its climb rather than ratcheting down each time it recovers.Rounding(rounding.rb) — every calculated weight lands on a multiple of 5 before it is written, because 2.5s on each side is the smallest change you can actually load.
Today the engine is reachable only from two rake tasks:
Which, from the seeded block 0, writes a Monday whose first lift reads: 45×5, 95×5, 115×3 and 135×2 as the warmup ramp, then 150, 155, 160 and 165 for 3 — the 4×5 at 155 the program asked for, converted and ascended.
Block 0 is four weeks, the last of them a deload, and only week one is written at a load. Lift week one as prescribed and week two generates at 170 for 3, week three at 175; fall short in week three and the deload comes out at 150 rather than 160. Generate each week when you reach it: the load is computed when the week is written and then left alone, so regenerating never rewrites a session you have already trained.
Deployment
render.yaml is the checked-in Render blueprint: bundle install to build, bundle exec rake db:migrate && bundle exec rake library:exercises as the pre-deploy command, and
bundle exec puma -C config/puma.rb to start. Secrets are marked sync: false so the
values already set in the dashboard are left alone — regenerating SESSION_SECRET would
log everyone out, and regenerating OAUTH_JWT_PRIVATE_KEY would stop every issued access
token from verifying.
config/puma.rb holds the thread count, the worker count and the hook forked workers need,
and binds $PORT itself, which is why the start command carries no -p. Puma finds that
file by name rather than by flag, so a local bundle exec rackup config.ru picks up the
same thread count and keeps its own port. The one thing production does differently is
that WEB_CONCURRENCY can be turned above zero there, and then the app is preloaded and
each worker drops the Postgres connections it inherited.
MCP server
Tectonic exposes a Model Context Protocol endpoint so an LLM client can act on an account's data through audited, per-account tools. The framework around them — transport, auth, scoping, auditing, guardrails — makes adding a tool cheap; see "Adding a tool".
The program tools are the ones that make "plan my training" possible rather than "log
what I did". create_program writes a whole block — weeks, days, lifts — in one call,
because a block composed one lift at a time is dozens of round trips that can fail
half-written. Everything after that is per-object, because revising a plan is: change
this lift, move that day, add a week like the last one. generate_program_week turns a
written week into real sessions with their warmup ramps, and is idempotent on the
program day, so running it twice changes nothing.
The three scale tools are read-only and stay that way: a measurement comes from an instrument, and the connector's job is to report it honestly rather than to record one. Each reports per source and unit and never averages across them — a scale's body-fat figure and a caliper's are different measurements wearing one name — and each returns a summary with its sample size by default, because a year of readings is hundreds of rows that say one thing. Derived figures carry their uncertainty: "27.9%, day-to-day variation around ±1.5%" rather than a bare number, so nobody reads a difference the instrument cannot resolve as progress. They compute no goal weight, no target rate, and no verdict.
The endpoint is a plain Rack app mounted at /mcp in config.ru, entirely outside
Roda's sessions, CSRF, and assets. It uses the mcp gem (pinned to 1.2.0) and is
constructed stateless (stateless: true): each POST is self-contained, no session
or SSE state is held in memory, so there is no single-process constraint — it scales
horizontally like any other request.
Authentication (OAuth 2.1)
Access is by OAuth 2.1, served by Rodauth (rodauth-oauth) in the same app. An LLM
client (Claude, ChatGPT, …) connects to the MCP URL, registers itself via Dynamic
Client Registration, and the user authorizes it on the consent page. Access tokens are
short-lived RS256 JWTs the MCP endpoint verifies locally — signature, expiry, and
audience (RFC 8707) — with no database lookup. There is nothing to mint or paste.
Discovery lives at the root, per the MCP authorization spec:
/.well-known/oauth-protected-resource— RFC 9728; names this resource and its authorization server./.well-known/oauth-authorization-server— RFC 8414; the authorize/token/register endpoints, scopes, and S256 PKCE.
For a headless caller (a script or CLI), register a confidential client and use the client-credentials grant:
The endpoint
- URL:
https://<host>/mcp(locallyhttp://localhost:9292/mcp). - Every request needs
Authorization: Bearer <jwt>. Missing, malformed, expired, wrong-audience, or badly signed tokens are rejected with401— carrying aWWW-Authenticatechallenge that points at the protected-resource metadata — before the request reaches the transport. - DNS-rebinding protection is on. Loopback hosts are always allowed; a deployed host
must be listed in
MCP_ALLOWED_HOSTS(and browser origins inMCP_ALLOWED_ORIGINS).
Configuration (all read from the environment):
Connecting it as a custom connector
In a client that supports remote MCP servers (Claude, ChatGPT), add a custom connector:
- Type: HTTP / "Streamable HTTP" MCP server.
- URL:
https://<host>/mcp. - Authentication: OAuth — the client discovers the authorization server from the
URL and walks you through the consent page; there is no token to paste. ChatGPT's
standard connector additionally requires
search/fetchtools, which the server exposes.
Or point any MCP-capable client at the URL. A quick check with curl, using a JWT obtained from the OAuth flow (or the client-credentials grant above):
Adding a tool
This is the point of the whole setup. Auth, per-account scoping, argument validation,
the write kill switch, refuse-and-explain errors, and audit logging all live in the base
class, so a new tool is: subclass, name it, declare a schema, declare a scope, write
perform, register it. whoami is the worked example
(lib/tectonic/mcp/tools/whoami.rb):
Then register it in lib/tectonic/mcp/server.rb:
What you get for free:
contextis account-scoped. It exposes only that account's datasets —context.workouts,context.exercises,context.sets— each already filtered to the authenticated account. There is noaccount_idsetter and no accessor that returns an unscoped model, so a cross-account query is unexpressible through the context. A tool must never accept anaccount_idargument or reach for a global model constant.- Scope enforcement. A token lacking the tool's declared scope is refused before
performruns. - Argument validation. The gem validates the incoming arguments against your
input_schemaand returns a readable error the model can correct from. - Refuse and explain. Return
refuse('...')(or raiseTool::Refusal) with a message written for a model to read and act on — never a stack trace. Domain rules (weight sanity bounds, refusing to touch completed work, date idempotency) belong in the tool body via this path. - Auditing. Every write lands an
mcp_audit_logrow on success and failure. Reads are audited only if the tool declaresaudit_reads. - The kill switch. A write tool automatically refuses when
MCP_WRITES_ENABLEDis off.
Return results with ok(text, structured: {...}) for success or refuse(message) for a
handled failure. A write tool declares scope :write; reads declare scope :read.
來源:README.md,提交 76a4797
工具
0版本歷史
1- v0.1.0最新Sep 16, 2026
評分
已滿足適用檢查項的 75%
該評分衡量此收錄條目的完整程度,依據註冊中心條目、端點與公開來源計算得出。它不是對伺服器本身的評價,也不表示伺服器安全或目前可用:可連通性是帶日期的檢查結果,評分也不包含安裝量或使用者評價。
- 近 30 天內可連通必需25 分
- 已宣告認證方式必需10 分
- 已列出工具15 分
請讓伺服器在無憑證時回應 tools/list,或發布可在沙箱中無憑證啟動的軟體包。
- 描述必需15 分
- 圖示10 分
- 文件15 分
- 授權條款10 分
請在儲存庫或註冊中心條目中宣告授權條款。