feat: add setup, browser login, and an install homepage
setup registers or logs in and writes ~/.verae/auth.properties without printing the JWT. A caller bearer is used for stamp and verify instead of the host login. VERAE_MCP_OAUTH=1 serves the browser account page. site/index.html is the public install guide.
This commit is contained in:
parent
b89c4ad4d2
commit
c3e0cd3abb
18 changed files with 974 additions and 105 deletions
35
README.md
35
README.md
|
|
@ -6,8 +6,40 @@ Verae MCP timestamping server. Hash-only stamps via leaf locald/NATS.
|
||||||
|
|
||||||
The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. `signup` creates a `user` account with no existing session. `login` stores the VeraeTime JWT on this host. `session` checks it. `create_user` adds an account when that JWT is an admin.
|
The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. `signup` creates a `user` account with no existing session. `login` stores the VeraeTime JWT on this host. `session` checks it. `create_user` adds an account when that JWT is an admin.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Install](#install)
|
||||||
|
- [Usage](#usage)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
On the machine that will run the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvx verae-mcp-timestamping-server setup
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup` asks whether this is a new account or an existing one, then writes `~/.verae/auth.properties` (mode 600). It prints the username, role, and expiry. It does not print the token. `uvx verae-mcp-timestamping-server session` prints the same check.
|
||||||
|
|
||||||
|
Assistant config names the command or the public URL. It does not contain the token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"verae-timestamping": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["verae-mcp-timestamping-server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For the public server, set `url` to `https://mcp.veraetime.net/mcp`. With `VERAE_MCP_OAUTH=1` and `VERAE_MCP_PUBLIC_URL=https://mcp.veraetime.net`, the first connection opens a browser page to create an account or log in. The assistant stores that token in its own OAuth file.
|
||||||
|
|
||||||
|
Development checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /Users/marchon/research/modelcontextprotocol/verae-mcp-timestamping-server
|
cd /Users/marchon/research/modelcontextprotocol/verae-mcp-timestamping-server
|
||||||
uv sync --group dev
|
uv sync --group dev
|
||||||
|
|
@ -59,10 +91,11 @@ open docs/html/index.html
|
||||||
|
|
||||||
Sources: `docs/operator-guide.md`, `docs/architecture.md`, `docs/api.md`, `docs/standards.md`.
|
Sources: `docs/operator-guide.md`, `docs/architecture.md`, `docs/api.md`, `docs/standards.md`.
|
||||||
|
|
||||||
Sample page (talks to Streamable HTTP on this machine):
|
Install page and stamp sample:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
open site/index.html
|
open site/index.html
|
||||||
|
open site/sample.html
|
||||||
```
|
```
|
||||||
|
|
||||||
Public copy of the page and the docs: `https://mcp.georgelambert.org/`.
|
Public copy of the page and the docs: `https://mcp.georgelambert.org/`.
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ Returns `valid`, `username`, `role`, and `expires_at`.
|
||||||
| `password` | required | New password |
|
| `password` | required | New password |
|
||||||
| `role` | required | `user` or `admin` |
|
| `role` | required | `user` or `admin` |
|
||||||
|
|
||||||
`POST /auth/users` with the stored JWT. Refuses unless `session` reports role `admin`. Returns `id`, `username`, and `role`. There is no public signup; the first admin already exists on the timestamping service.
|
`POST /auth/users` with the stored JWT. Refuses unless `session` reports role `admin`. Returns `id`, `username`, and `role`. Public registration is `signup`. `create_user` is how an admin sets a role.
|
||||||
|
|
||||||
A stored login is refreshed when `stamp` or `verify` runs and `expires_at` is missing or within 5 minutes, if `username` and `password` are in the auth file.
|
A stored login is refreshed when `stamp` or `verify` runs and `expires_at` is missing or within 5 minutes, if `username` and `password` are in the auth file.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -292,9 +292,9 @@ replace the JWT stored for <code>stamp</code>. A new account calls
|
||||||
</table>
|
</table>
|
||||||
<p><code>POST /auth/users</code> with the stored JWT. Refuses unless
|
<p><code>POST /auth/users</code> with the stored JWT. Refuses unless
|
||||||
<code>session</code> reports role <code>admin</code>. Returns
|
<code>session</code> reports role <code>admin</code>. Returns
|
||||||
<code>id</code>, <code>username</code>, and <code>role</code>. There is
|
<code>id</code>, <code>username</code>, and <code>role</code>. Public
|
||||||
no public signup; the first admin already exists on the timestamping
|
registration is <code>signup</code>. <code>create_user</code> is how an
|
||||||
service.</p>
|
admin sets a role.</p>
|
||||||
<p>A stored login is refreshed when <code>stamp</code> or
|
<p>A stored login is refreshed when <code>stamp</code> or
|
||||||
<code>verify</code> runs and <code>expires_at</code> is missing or
|
<code>verify</code> runs and <code>expires_at</code> is missing or
|
||||||
within 5 minutes, if <code>username</code> and <code>password</code> are
|
within 5 minutes, if <code>username</code> and <code>password</code> are
|
||||||
|
|
|
||||||
|
|
@ -112,12 +112,20 @@
|
||||||
<code>site/index.html</code> on the same machine, or put TLS and an
|
<code>site/index.html</code> on the same machine, or put TLS and an
|
||||||
access control in front of a non-loopback bind you choose yourself.</p>
|
access control in front of a non-loopback bind you choose yourself.</p>
|
||||||
<h2 id="global-token">Global token</h2>
|
<h2 id="global-token">Global token</h2>
|
||||||
|
<p>A new machine runs
|
||||||
|
<code>uvx verae-mcp-timestamping-server setup</code>. That creates an
|
||||||
|
account or logs in and writes <code>~/.verae/auth.properties</code>
|
||||||
|
(mode 600). It prints username, role, and expiry, not the JWT.
|
||||||
|
<code>session</code> checks that file. Starting the server with no login
|
||||||
|
prints the setup command on stderr.</p>
|
||||||
<p><code>signup</code> creates a role <code>user</code> account with no
|
<p><code>signup</code> creates a role <code>user</code> account with no
|
||||||
bearer token. Call <code>login</code> with that username and password to
|
bearer token. <code>create_user</code> adds an account when the stored
|
||||||
store the JWT in <code>~/.verae/auth.properties</code> (mode 600). The
|
role is <code>admin</code>.</p>
|
||||||
server uses it for <code>scope=global</code> without a restart.
|
<p>Set <code>VERAE_MCP_OAUTH=1</code> and
|
||||||
<code>session</code> checks that JWT. <code>create_user</code> adds an
|
<code>VERAE_MCP_PUBLIC_URL</code> to the public HTTPS origin to require
|
||||||
account when the stored role is <code>admin</code>.</p>
|
a browser login on the HTTP MCP endpoint. That login’s JWT is the
|
||||||
|
caller’s bearer for <code>stamp</code> and is not written over the host
|
||||||
|
file.</p>
|
||||||
<div class="sourceCode" id="cb5"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># or skip the tool and export a token yourself:</span></span>
|
<div class="sourceCode" id="cb5"><pre class="sourceCode sh"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># or skip the tool and export a token yourself:</span></span>
|
||||||
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">VERAE_GLOBAL_TOKEN</span><span class="op">=</span>...</span></code></pre></div>
|
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="bu">export</span> <span class="va">VERAE_GLOBAL_TOKEN</span><span class="op">=</span>...</span></code></pre></div>
|
||||||
<h2 id="tests">Tests</h2>
|
<h2 id="tests">Tests</h2>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,11 @@ A page served from the public internet still cannot call `127.0.0.1` from curren
|
||||||
|
|
||||||
## Global token
|
## Global token
|
||||||
|
|
||||||
`signup` creates a role `user` account with no bearer token. Call `login` with that username and password to store the JWT in `~/.verae/auth.properties` (mode 600). The server uses it for `scope=global` without a restart. `session` checks that JWT. `create_user` adds an account when the stored role is `admin`.
|
A new machine runs `uvx verae-mcp-timestamping-server setup`. That creates an account or logs in and writes `~/.verae/auth.properties` (mode 600). It prints username, role, and expiry, not the JWT. `session` checks that file. Starting the server with no login prints the setup command on stderr.
|
||||||
|
|
||||||
|
`signup` creates a role `user` account with no bearer token. `create_user` adds an account when the stored role is `admin`.
|
||||||
|
|
||||||
|
Set `VERAE_MCP_OAUTH=1` and `VERAE_MCP_PUBLIC_URL` to the public HTTPS origin to require a browser login on the HTTP MCP endpoint. That login's JWT is the caller's bearer for `stamp` and is not written over the host file.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# or skip the tool and export a token yourself:
|
# or skip the tool and export a token yourself:
|
||||||
|
|
|
||||||
BIN
docs/pdf/api.pdf
BIN
docs/pdf/api.pdf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
site/assets/VeraeFullLogo.png
Normal file
BIN
site/assets/VeraeFullLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
282
site/index.html
282
site/index.html
|
|
@ -3,89 +3,217 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Verae timestamping MCP sample</title>
|
<title>Install Verae timestamping</title>
|
||||||
|
<meta name="description" content="Register a VeraeTime account, store the token on your machine, and connect Claude, Cursor, or another MCP client.">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="icon" href="assets/VeraeFullLogo.png">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Georgia, serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.45; }
|
:root {
|
||||||
label { display: block; margin-top: 0.8rem; }
|
--ink: oklch(0.21 0.020 310);
|
||||||
input, select, button, textarea { font: inherit; width: 100%; box-sizing: border-box; }
|
--ink-soft: oklch(0.42 0.018 310);
|
||||||
button { width: auto; margin-top: 1rem; }
|
--ink-mute: oklch(0.60 0.014 310);
|
||||||
pre { white-space: pre-wrap; background: #f4f4f4; padding: 0.8rem; }
|
--paper: oklch(0.992 0.004 90);
|
||||||
code { font-family: ui-monospace, monospace; }
|
--soft: oklch(0.972 0.008 85);
|
||||||
|
--hair: oklch(0.90 0.010 80);
|
||||||
|
--purple: oklch(0.32 0.110 310);
|
||||||
|
--purple-mid: oklch(0.42 0.130 312);
|
||||||
|
--purple-wash: oklch(0.96 0.020 312);
|
||||||
|
--gold: oklch(0.80 0.090 85);
|
||||||
|
--dark: oklch(0.19 0.022 312);
|
||||||
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
a { color: var(--purple); }
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 28px 0;
|
||||||
|
}
|
||||||
|
.brand img { height: 46px; width: auto; display: block; }
|
||||||
|
nav { display: flex; gap: 22px; font-size: 14px; font-weight: 600; }
|
||||||
|
nav a { color: var(--ink-soft); text-decoration: none; }
|
||||||
|
nav a:hover { color: var(--purple); }
|
||||||
|
.hero {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 64px 28px 24px;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--purple-mid);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: Fraunces, Georgia, serif;
|
||||||
|
font-weight: 560;
|
||||||
|
font-size: clamp(40px, 6vw, 68px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin: 12px 0 18px;
|
||||||
|
max-width: 16ch;
|
||||||
|
}
|
||||||
|
.lede { max-width: 40rem; font-size: 18px; color: var(--ink-soft); margin: 0; }
|
||||||
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 12px 28px 80px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 36px; }
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--hair);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 22px 22px 18px;
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 18px; margin: 0 0 8px; }
|
||||||
|
.card p { margin: 0; color: var(--ink-soft); font-size: 15px; }
|
||||||
|
.step { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--purple-mid); }
|
||||||
|
h2.section {
|
||||||
|
font-family: Fraunces, Georgia, serif;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 560;
|
||||||
|
margin: 56px 0 8px;
|
||||||
|
}
|
||||||
|
.section-note { color: var(--ink-soft); max-width: 42rem; margin-top: 0; }
|
||||||
|
pre {
|
||||||
|
background: var(--dark);
|
||||||
|
color: oklch(0.95 0.01 90);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; }
|
||||||
|
.path { background: var(--purple-wash); padding: 2px 6px; border-radius: 6px; }
|
||||||
|
ol.steps { padding-left: 1.2rem; max-width: 44rem; }
|
||||||
|
ol.steps li { margin: 0.55rem 0; }
|
||||||
|
.gold { height: 3px; width: 72px; background: var(--gold); border: 0; margin: 28px 0; }
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--hair);
|
||||||
|
padding: 28px;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
footer .wrap { padding: 0; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
header { padding-top: 18px; }
|
||||||
|
nav { gap: 14px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Verae timestamping MCP sample</h1>
|
<header>
|
||||||
<p>This page calls a <strong>Model Context Protocol</strong> (MCP) server. The three letters are MCP. The server name is <code>verae-timestamping</code>. It stamps a SHA-256 digest. It does not upload a file.</p>
|
<a class="brand" href="https://www.verae.com/"><img src="assets/VeraeFullLogo.png" alt="Verae"></a>
|
||||||
<p>Start the server on this machine, then submit. A page on the public internet cannot reach <code>127.0.0.1</code>.</p>
|
<nav>
|
||||||
<pre>VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server</pre>
|
<a href="#register">Register</a>
|
||||||
<form id="stamp-form">
|
<a href="#clients">Clients</a>
|
||||||
<label>Server URL <input id="url" value="http://127.0.0.1:8000/mcp"></label>
|
<a href="#token">Token</a>
|
||||||
<label>SHA-256 <input id="sha" value="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" spellcheck="false"></label>
|
<a href="sample.html">Try a stamp</a>
|
||||||
<label>Scope
|
</nav>
|
||||||
<select id="scope">
|
</header>
|
||||||
<option>local</option>
|
<main>
|
||||||
<option>shared</option>
|
<section class="hero">
|
||||||
<option>global</option>
|
<p class="eyebrow">Model Context Protocol</p>
|
||||||
</select>
|
<h1>Install Verae timestamping.</h1>
|
||||||
</label>
|
<p class="lede">Register once. The token stays in a private file on your machine, or in your assistant’s own login store. It does not go in the chat, and it does not go in the client config.</p>
|
||||||
<label>Tool
|
<hr class="gold">
|
||||||
<select id="tool">
|
</section>
|
||||||
<option value="stamp">stamp</option>
|
<div class="wrap">
|
||||||
<option value="verify">verify</option>
|
<section id="register">
|
||||||
<option value="hash_report">hash_report</option>
|
<h2 class="section">Register on your machine</h2>
|
||||||
</select>
|
<p class="section-note">This is the install for a person or a server you control. <code>uv</code> runs the command. You do not clone this repository.</p>
|
||||||
</label>
|
<pre>uvx verae-mcp-timestamping-server setup</pre>
|
||||||
<label>Receipt id (verify) <input id="receipt" placeholder="from a previous stamp"></label>
|
<ol class="steps">
|
||||||
<button type="submit">Call tool</button>
|
<li>Choose <strong>new</strong> to create a VeraeTime account, or <strong>existing</strong> to log in.</li>
|
||||||
</form>
|
<li>Enter a username and password. The command calls <code>https://api.veraetime.net</code>.</li>
|
||||||
<h2>Result</h2>
|
<li>It writes <span class="path">~/.verae/auth.properties</span> with mode <code>600</code> and prints your username, role, and expiry. It does not print the token.</li>
|
||||||
<pre id="out">Waiting.</pre>
|
<li>Check it later with <code>uvx verae-mcp-timestamping-server session</code>.</li>
|
||||||
<p>Docs in this repo: <a href="../docs/html/index.html">HTML</a> · <a href="../docs/pdf/standards.pdf">standards PDF</a></p>
|
</ol>
|
||||||
<p>Same files on the public host: <a href="/docs/html/index.html">HTML</a> · <a href="/docs/pdf/standards.pdf">standards PDF</a></p>
|
<p>If you start the server before registering, it prints those same steps on its error stream and still starts. Global stamps need the file. Local stamps do not.</p>
|
||||||
<script>
|
</section>
|
||||||
const out = document.getElementById("out");
|
|
||||||
function rpc(id, method, params) {
|
<section id="clients">
|
||||||
return { jsonrpc: "2.0", id, method, params };
|
<h2 class="section">Connect an assistant</h2>
|
||||||
|
<p class="section-note">Pick the environment you use. The config names the program. It does not contain the token.</p>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<p class="step">Claude Desktop</p>
|
||||||
|
<h2>macOS and Windows</h2>
|
||||||
|
<p>macOS file: <span class="path">~/Library/Application Support/Claude/claude_desktop_config.json</span>. Windows file: <span class="path">%APPDATA%\Claude\claude_desktop_config.json</span>. Restart Claude after saving.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<p class="step">Cursor</p>
|
||||||
|
<h2>Project or user</h2>
|
||||||
|
<p>User file: <span class="path">~/.cursor/mcp.json</span>. A project file is <span class="path">.cursor/mcp.json</span> in that project. Restart Cursor after saving.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<p class="step">VS Code and others</p>
|
||||||
|
<h2>mcp.json</h2>
|
||||||
|
<p>The same JSON works for any client that reads the Model Context Protocol <code>mcpServers</code> map, including VS Code with an MCP extension.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<h2 class="section">Run it on your computer</h2>
|
||||||
|
<p class="section-note">The assistant starts the server. Your login in <span class="path">~/.verae/auth.properties</span> is the one it uses.</p>
|
||||||
|
<pre>{
|
||||||
|
"mcpServers": {
|
||||||
|
"verae-timestamping": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["verae-mcp-timestamping-server"]
|
||||||
}
|
}
|
||||||
async function post(url, body, session) {
|
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json, text/event-stream"
|
|
||||||
};
|
|
||||||
if (session) headers["Mcp-Session-Id"] = session;
|
|
||||||
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
||||||
const text = await res.text();
|
|
||||||
const sessionId = res.headers.get("mcp-session-id") || session;
|
|
||||||
let parsed = text;
|
|
||||||
try { parsed = JSON.parse(text); } catch (e) { /* SSE or plain text */ }
|
|
||||||
if (!res.ok) throw new Error(res.status + " " + text);
|
|
||||||
return { parsed, sessionId };
|
|
||||||
}
|
}
|
||||||
document.getElementById("stamp-form").addEventListener("submit", async (ev) => {
|
}</pre>
|
||||||
ev.preventDefault();
|
<h2 class="section">Use the public server</h2>
|
||||||
const url = document.getElementById("url").value.trim();
|
<p class="section-note">No local program. The assistant connects to this host. When per-user sign-in is on, the first connection opens a browser page at <a href="/account">/account</a> to create an account or log in. That token stays in the assistant’s OAuth store, not in this file.</p>
|
||||||
const tool = document.getElementById("tool").value;
|
<pre>{
|
||||||
const sha = document.getElementById("sha").value.trim();
|
"mcpServers": {
|
||||||
const scope = document.getElementById("scope").value;
|
"verae-timestamping": {
|
||||||
const receipt = document.getElementById("receipt").value.trim();
|
"url": "https://mcp.veraetime.net/mcp"
|
||||||
out.textContent = "Calling " + url + " …";
|
|
||||||
try {
|
|
||||||
const init = await post(url, rpc(1, "initialize", {
|
|
||||||
protocolVersion: "2025-03-26",
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "verae-mcp-sample-page", version: "0.1.0" }
|
|
||||||
}));
|
|
||||||
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
|
|
||||||
let args;
|
|
||||||
if (tool === "verify") args = { receipt_id: receipt, want_scope: scope };
|
|
||||||
else if (tool === "hash_report") args = { sha256: sha, want_scope: scope };
|
|
||||||
else args = { sha256: sha, scope };
|
|
||||||
const call = await post(url, rpc(2, "tools/call", { name: tool, arguments: args }), init.sessionId);
|
|
||||||
out.textContent = JSON.stringify({ initialize: init.parsed, call: call.parsed }, null, 2);
|
|
||||||
} catch (err) {
|
|
||||||
out.textContent = String(err) + "\n\nIf this page is not on the same machine as the server, the browser will block the call.";
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
</script>
|
}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="token">
|
||||||
|
<h2 class="section">Where the token lives</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<p class="step">Your machine</p>
|
||||||
|
<h2>~/.verae/auth.properties</h2>
|
||||||
|
<p>Mode 600, owned by you. Holds the JWT, its expiry, your username, and your password so a stamp can log in again before the 24-hour token expires. Do not commit this file.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<p class="step">Your assistant</p>
|
||||||
|
<h2>OAuth file</h2>
|
||||||
|
<p>A browser login on the public server gives the assistant a VeraeTime JWT and a refresh token. The Python client stores that under <span class="path">~/.verae/oauth/mcp-tokens.json</span>, also mode 600.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<p class="step">Never</p>
|
||||||
|
<h2>Chat or mcp.json</h2>
|
||||||
|
<p>Do not paste the token into a conversation or into the client config. <code>session</code> shows the username and expiry if you need to confirm the login.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<div class="wrap">
|
||||||
|
<span>Verae timestamping · <a href="https://www.verae.com/">verae.com</a></span>
|
||||||
|
<span><a href="sample.html">Try a stamp</a> · <a href="https://api.veraetime.net/docs/swagger/">API docs</a></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
91
site/sample.html
Normal file
91
site/sample.html
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Verae timestamping MCP sample</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Georgia, serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.45; }
|
||||||
|
label { display: block; margin-top: 0.8rem; }
|
||||||
|
input, select, button, textarea { font: inherit; width: 100%; box-sizing: border-box; }
|
||||||
|
button { width: auto; margin-top: 1rem; }
|
||||||
|
pre { white-space: pre-wrap; background: #f4f4f4; padding: 0.8rem; }
|
||||||
|
code { font-family: ui-monospace, monospace; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Verae timestamping MCP sample</h1>
|
||||||
|
<p>This page calls a <strong>Model Context Protocol</strong> (MCP) server. The three letters are MCP. The server name is <code>verae-timestamping</code>. It stamps a SHA-256 digest. It does not upload a file.</p>
|
||||||
|
<p>Start the server on this machine, then submit. A page on the public internet cannot reach <code>127.0.0.1</code>.</p>
|
||||||
|
<pre>VERAE_MCP_CORS=1 VERAE_MCP_TRANSPORT=streamable-http uv run verae-mcp-timestamping-server</pre>
|
||||||
|
<form id="stamp-form">
|
||||||
|
<label>Server URL <input id="url" value="http://127.0.0.1:8000/mcp"></label>
|
||||||
|
<label>SHA-256 <input id="sha" value="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" spellcheck="false"></label>
|
||||||
|
<label>Scope
|
||||||
|
<select id="scope">
|
||||||
|
<option>local</option>
|
||||||
|
<option>shared</option>
|
||||||
|
<option>global</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tool
|
||||||
|
<select id="tool">
|
||||||
|
<option value="stamp">stamp</option>
|
||||||
|
<option value="verify">verify</option>
|
||||||
|
<option value="hash_report">hash_report</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Receipt id (verify) <input id="receipt" placeholder="from a previous stamp"></label>
|
||||||
|
<button type="submit">Call tool</button>
|
||||||
|
</form>
|
||||||
|
<h2>Result</h2>
|
||||||
|
<pre id="out">Waiting.</pre>
|
||||||
|
<p>Docs in this repo: <a href="../docs/html/index.html">HTML</a> · <a href="../docs/pdf/standards.pdf">standards PDF</a></p>
|
||||||
|
<p>Same files on the public host: <a href="/docs/html/index.html">HTML</a> · <a href="/docs/pdf/standards.pdf">standards PDF</a></p>
|
||||||
|
<script>
|
||||||
|
const out = document.getElementById("out");
|
||||||
|
function rpc(id, method, params) {
|
||||||
|
return { jsonrpc: "2.0", id, method, params };
|
||||||
|
}
|
||||||
|
async function post(url, body, session) {
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream"
|
||||||
|
};
|
||||||
|
if (session) headers["Mcp-Session-Id"] = session;
|
||||||
|
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||||
|
const text = await res.text();
|
||||||
|
const sessionId = res.headers.get("mcp-session-id") || session;
|
||||||
|
let parsed = text;
|
||||||
|
try { parsed = JSON.parse(text); } catch (e) { /* SSE or plain text */ }
|
||||||
|
if (!res.ok) throw new Error(res.status + " " + text);
|
||||||
|
return { parsed, sessionId };
|
||||||
|
}
|
||||||
|
document.getElementById("stamp-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const url = document.getElementById("url").value.trim();
|
||||||
|
const tool = document.getElementById("tool").value;
|
||||||
|
const sha = document.getElementById("sha").value.trim();
|
||||||
|
const scope = document.getElementById("scope").value;
|
||||||
|
const receipt = document.getElementById("receipt").value.trim();
|
||||||
|
out.textContent = "Calling " + url + " …";
|
||||||
|
try {
|
||||||
|
const init = await post(url, rpc(1, "initialize", {
|
||||||
|
protocolVersion: "2025-03-26",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "verae-mcp-sample-page", version: "0.1.0" }
|
||||||
|
}));
|
||||||
|
await post(url, { jsonrpc: "2.0", method: "notifications/initialized" }, init.sessionId);
|
||||||
|
let args;
|
||||||
|
if (tool === "verify") args = { receipt_id: receipt, want_scope: scope };
|
||||||
|
else if (tool === "hash_report") args = { sha256: sha, want_scope: scope };
|
||||||
|
else args = { sha256: sha, scope };
|
||||||
|
const call = await post(url, rpc(2, "tools/call", { name: tool, arguments: args }), init.sessionId);
|
||||||
|
out.textContent = JSON.stringify({ initialize: init.parsed, call: call.parsed }, null, 2);
|
||||||
|
} catch (err) {
|
||||||
|
out.textContent = String(err) + "\n\nIf this page is not on the same machine as the server, the browser will block the call.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -13,6 +13,7 @@ import re
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from contextvars import ContextVar
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
@ -28,6 +29,9 @@ from verae_mcp_timestamping_server.ledger import (
|
||||||
DEFAULT_GLOBAL_URL = "https://api.veraetime.net"
|
DEFAULT_GLOBAL_URL = "https://api.veraetime.net"
|
||||||
# Refresh a stored login this long before expires_at.
|
# Refresh a stored login this long before expires_at.
|
||||||
TOKEN_REFRESH_SKEW = timedelta(minutes=5)
|
TOKEN_REFRESH_SKEW = timedelta(minutes=5)
|
||||||
|
# Bearer for this MCP request. When set, stamp and verify use it and do not
|
||||||
|
# refresh the host login.
|
||||||
|
request_token: ContextVar[str | None] = ContextVar("verae_request_token", default=None)
|
||||||
|
|
||||||
|
|
||||||
class HttpTransport(Protocol):
|
class HttpTransport(Protocol):
|
||||||
|
|
@ -223,7 +227,8 @@ class GlobalClient:
|
||||||
if self.needs_refresh():
|
if self.needs_refresh():
|
||||||
self.login(self._username, self._password)
|
self.login(self._username, self._password)
|
||||||
|
|
||||||
def login(self, username: str, password: str) -> LoginResult:
|
def authenticate(self, username: str, password: str) -> tuple[LoginResult, str]:
|
||||||
|
"""Check a username and password. Does not store the JWT."""
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise ValueError("username and password are required")
|
raise ValueError("username and password are required")
|
||||||
res = self.http.request(
|
res = self.http.request(
|
||||||
|
|
@ -243,10 +248,21 @@ class GlobalClient:
|
||||||
raise ValueError("login response missing token")
|
raise ValueError("login response missing token")
|
||||||
user = parsed.get("user") if isinstance(parsed.get("user"), dict) else {}
|
user = parsed.get("user") if isinstance(parsed.get("user"), dict) else {}
|
||||||
expires_at = str(parsed.get("expiresAt") or "")
|
expires_at = str(parsed.get("expiresAt") or "")
|
||||||
|
return (
|
||||||
|
LoginResult(
|
||||||
|
username=str(user.get("username") or username),
|
||||||
|
role=str(user.get("role") or ""),
|
||||||
|
expires_at=expires_at,
|
||||||
|
),
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> LoginResult:
|
||||||
|
result, token = self.authenticate(username, password)
|
||||||
self.token = token
|
self.token = token
|
||||||
self._username = username
|
self._username = username
|
||||||
self._password = password
|
self._password = password
|
||||||
self.expires_at = parse_expiry(expires_at)
|
self.expires_at = parse_expiry(result.expires_at)
|
||||||
path = self.auth_path or default_auth_path()
|
path = self.auth_path or default_auth_path()
|
||||||
self.auth_path = path
|
self.auth_path = path
|
||||||
write_properties(
|
write_properties(
|
||||||
|
|
@ -255,16 +271,21 @@ class GlobalClient:
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"id_token": token,
|
"id_token": token,
|
||||||
"auth_token": token,
|
"auth_token": token,
|
||||||
"expires_at": expires_at,
|
"expires_at": result.expires_at,
|
||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return LoginResult(
|
return result
|
||||||
username=str(user.get("username") or username),
|
|
||||||
role=str(user.get("role") or ""),
|
def validate_bearer(self, token: str) -> SessionResult:
|
||||||
expires_at=expires_at,
|
"""Check a caller-supplied JWT without replacing the host login."""
|
||||||
)
|
saved = self.token
|
||||||
|
self.token = token
|
||||||
|
try:
|
||||||
|
return self.session()
|
||||||
|
finally:
|
||||||
|
self.token = saved
|
||||||
|
|
||||||
def session(self) -> SessionResult:
|
def session(self) -> SessionResult:
|
||||||
if not self.token:
|
if not self.token:
|
||||||
|
|
@ -340,7 +361,19 @@ class GlobalClient:
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def submit(self, digest: str, cube_id: str = "") -> Receipt:
|
def submit(self, digest: str, cube_id: str = "") -> Receipt:
|
||||||
|
override = request_token.get()
|
||||||
|
saved = self.token
|
||||||
|
if override:
|
||||||
|
self.token = override
|
||||||
|
else:
|
||||||
self.ensure_token()
|
self.ensure_token()
|
||||||
|
try:
|
||||||
|
return self._submit(digest, cube_id)
|
||||||
|
finally:
|
||||||
|
if override:
|
||||||
|
self.token = saved
|
||||||
|
|
||||||
|
def _submit(self, digest: str, cube_id: str = "") -> Receipt:
|
||||||
url = f"{self.base_url}/api/timestamp"
|
url = f"{self.base_url}/api/timestamp"
|
||||||
res = self.http.request(
|
res = self.http.request(
|
||||||
"POST",
|
"POST",
|
||||||
|
|
@ -416,7 +449,19 @@ class GlobalClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
def verify_certificate(self, receipt_id: str) -> VerifyResult:
|
def verify_certificate(self, receipt_id: str) -> VerifyResult:
|
||||||
|
override = request_token.get()
|
||||||
|
saved = self.token
|
||||||
|
if override:
|
||||||
|
self.token = override
|
||||||
|
else:
|
||||||
self.ensure_token()
|
self.ensure_token()
|
||||||
|
try:
|
||||||
|
return self._verify_certificate(receipt_id)
|
||||||
|
finally:
|
||||||
|
if override:
|
||||||
|
self.token = saved
|
||||||
|
|
||||||
|
def _verify_certificate(self, receipt_id: str) -> VerifyResult:
|
||||||
url = f"{self.base_url}/api/verify"
|
url = f"{self.base_url}/api/verify"
|
||||||
res = self.http.request(
|
res = self.http.request(
|
||||||
"POST",
|
"POST",
|
||||||
|
|
|
||||||
251
src/verae_mcp_timestamping_server/oauth_login.py
Normal file
251
src/verae_mcp_timestamping_server/oauth_login.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
"""Browser signup and login for assistants that connect to this MCP server.
|
||||||
|
|
||||||
|
The access token issued here is the VeraeTime JWT. The assistant stores it
|
||||||
|
in its own OAuth file. This process does not put that JWT in mcp.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
from mcp.server.auth.provider import (
|
||||||
|
AccessToken,
|
||||||
|
AuthorizationCode,
|
||||||
|
AuthorizationParams,
|
||||||
|
RefreshToken,
|
||||||
|
TokenError,
|
||||||
|
construct_redirect_uri,
|
||||||
|
)
|
||||||
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||||
|
|
||||||
|
from verae_mcp_timestamping_server.global_client import GlobalClient, parse_expiry
|
||||||
|
|
||||||
|
_PAGE = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>VeraeTime account</title>
|
||||||
|
<style>
|
||||||
|
body { font: 16px/1.4 system-ui, sans-serif; max-width: 28rem; margin: 3rem auto; padding: 0 1rem; }
|
||||||
|
label { display: block; margin-top: 0.8rem; }
|
||||||
|
input { width: 100%; box-sizing: border-box; padding: 0.4rem; }
|
||||||
|
button { margin-top: 1rem; margin-right: 0.5rem; padding: 0.4rem 0.8rem; }
|
||||||
|
p.error { color: #9b1c1c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>VeraeTime</h1>
|
||||||
|
<p>Create an account or log in. The token stays in your assistant. It is not shown here.</p>
|
||||||
|
{error}
|
||||||
|
<form method="post" action="/account">
|
||||||
|
<input type="hidden" name="txn" value="{txn}">
|
||||||
|
<label>Username <input name="username" autocomplete="username" required></label>
|
||||||
|
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
||||||
|
<button name="mode" value="signup" type="submit">Create account</button>
|
||||||
|
<button name="mode" value="login" type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Pending:
|
||||||
|
client_id: str
|
||||||
|
params: AuthorizationParams
|
||||||
|
created: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Code:
|
||||||
|
code: str
|
||||||
|
client_id: str
|
||||||
|
params: AuthorizationParams
|
||||||
|
api_token: str
|
||||||
|
expires_at: int | None
|
||||||
|
subject: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Refresh:
|
||||||
|
token: str
|
||||||
|
client_id: str
|
||||||
|
scopes: list[str]
|
||||||
|
api_token: str
|
||||||
|
expires_at: int | None
|
||||||
|
subject: str
|
||||||
|
|
||||||
|
|
||||||
|
class VeraeOAuthProvider:
|
||||||
|
"""OAuth authorization server whose access token is a VeraeTime JWT."""
|
||||||
|
|
||||||
|
def __init__(self, api: GlobalClient, issuer: str) -> None:
|
||||||
|
self.api = api
|
||||||
|
self.issuer = issuer.rstrip("/")
|
||||||
|
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||||
|
self.pending: dict[str, _Pending] = {}
|
||||||
|
self.codes: dict[str, _Code] = {}
|
||||||
|
self.refresh: dict[str, _Refresh] = {}
|
||||||
|
|
||||||
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||||
|
return self.clients.get(client_id)
|
||||||
|
|
||||||
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||||
|
if not client_info.client_id:
|
||||||
|
client_info.client_id = secrets.token_urlsafe(24)
|
||||||
|
self.clients[client_info.client_id] = client_info
|
||||||
|
|
||||||
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||||
|
txn = secrets.token_urlsafe(32)
|
||||||
|
self.pending[txn] = _Pending(client_id=client.client_id, params=params, created=time.time())
|
||||||
|
return f"{self.issuer}/account?txn={txn}"
|
||||||
|
|
||||||
|
async def load_authorization_code(
|
||||||
|
self, client: OAuthClientInformationFull, authorization_code: str
|
||||||
|
) -> AuthorizationCode | None:
|
||||||
|
found = self.codes.get(authorization_code)
|
||||||
|
if found is None or found.client_id != client.client_id:
|
||||||
|
return None
|
||||||
|
return AuthorizationCode(
|
||||||
|
code=found.code,
|
||||||
|
scopes=["stamp"],
|
||||||
|
expires_at=time.time() + 300,
|
||||||
|
client_id=found.client_id,
|
||||||
|
code_challenge=found.params.code_challenge,
|
||||||
|
redirect_uri=found.params.redirect_uri,
|
||||||
|
redirect_uri_provided_explicitly=found.params.redirect_uri_provided_explicitly,
|
||||||
|
resource=found.params.resource,
|
||||||
|
subject=found.subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exchange_authorization_code(
|
||||||
|
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||||
|
) -> OAuthToken:
|
||||||
|
found = self.codes.pop(authorization_code.code, None)
|
||||||
|
if found is None or found.client_id != client.client_id:
|
||||||
|
raise TokenError("invalid_grant", "unknown authorization code")
|
||||||
|
return self._issue(found.client_id, found.api_token, found.expires_at, found.subject)
|
||||||
|
|
||||||
|
async def load_refresh_token(
|
||||||
|
self, client: OAuthClientInformationFull, refresh_token: str
|
||||||
|
) -> RefreshToken | None:
|
||||||
|
found = self.refresh.get(refresh_token)
|
||||||
|
if found is None or found.client_id != client.client_id:
|
||||||
|
return None
|
||||||
|
return RefreshToken(
|
||||||
|
token=found.token,
|
||||||
|
client_id=found.client_id,
|
||||||
|
scopes=list(found.scopes),
|
||||||
|
expires_at=found.expires_at,
|
||||||
|
subject=found.subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exchange_refresh_token(
|
||||||
|
self,
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
scopes: list[str],
|
||||||
|
) -> OAuthToken:
|
||||||
|
del scopes
|
||||||
|
found = self.refresh.get(refresh_token.token)
|
||||||
|
if found is None or found.client_id != client.client_id:
|
||||||
|
raise TokenError("invalid_grant", "unknown refresh token")
|
||||||
|
if found.expires_at is not None and found.expires_at <= int(time.time()):
|
||||||
|
self.refresh.pop(refresh_token.token, None)
|
||||||
|
raise TokenError("invalid_grant", "login expired; sign in again")
|
||||||
|
self.refresh.pop(refresh_token.token, None)
|
||||||
|
return self._issue(found.client_id, found.api_token, found.expires_at, found.subject)
|
||||||
|
|
||||||
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||||
|
seen = await asyncio.to_thread(self.api.validate_bearer, token)
|
||||||
|
if not seen.valid:
|
||||||
|
return None
|
||||||
|
expires = parse_expiry(seen.expires_at)
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id="verae",
|
||||||
|
scopes=["stamp"],
|
||||||
|
expires_at=int(expires.timestamp()) if expires else None,
|
||||||
|
subject=seen.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||||
|
self.refresh.pop(getattr(token, "token", ""), None)
|
||||||
|
|
||||||
|
def _issue(self, client_id: str, api_token: str, expires_at: int | None, subject: str) -> OAuthToken:
|
||||||
|
refresh = secrets.token_urlsafe(32)
|
||||||
|
self.refresh[refresh] = _Refresh(
|
||||||
|
token=refresh,
|
||||||
|
client_id=client_id,
|
||||||
|
scopes=["stamp"],
|
||||||
|
api_token=api_token,
|
||||||
|
expires_at=expires_at,
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
expires_in = None
|
||||||
|
if expires_at is not None:
|
||||||
|
expires_in = max(0, expires_at - int(time.time()))
|
||||||
|
return OAuthToken(
|
||||||
|
access_token=api_token,
|
||||||
|
expires_in=expires_in,
|
||||||
|
scope="stamp",
|
||||||
|
refresh_token=refresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _drop_old_pending(self) -> None:
|
||||||
|
cutoff = time.time() - 600
|
||||||
|
for key, item in list(self.pending.items()):
|
||||||
|
if item.created < cutoff:
|
||||||
|
del self.pending[key]
|
||||||
|
|
||||||
|
async def handle_account(self, request: Request) -> Response:
|
||||||
|
self._drop_old_pending()
|
||||||
|
if request.method == "GET":
|
||||||
|
txn = request.query_params.get("txn", "")
|
||||||
|
error = request.query_params.get("error", "")
|
||||||
|
return self._page(txn, error)
|
||||||
|
form = parse_qs((await request.body()).decode())
|
||||||
|
txn = (form.get("txn") or [""])[0]
|
||||||
|
username = (form.get("username") or [""])[0].strip()
|
||||||
|
password = (form.get("password") or [""])[0]
|
||||||
|
mode = (form.get("mode") or ["login"])[0]
|
||||||
|
pending = self.pending.get(txn)
|
||||||
|
if pending is None:
|
||||||
|
return self._page(txn, "This login page expired. Start the connection again.")
|
||||||
|
if not username or not password:
|
||||||
|
return self._page(txn, "Username and password are required.")
|
||||||
|
try:
|
||||||
|
if mode == "signup":
|
||||||
|
await asyncio.to_thread(self.api.signup, username, password)
|
||||||
|
logged, api_token = await asyncio.to_thread(self.api.authenticate, username, password)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self._page(txn, str(exc))
|
||||||
|
self.pending.pop(txn, None)
|
||||||
|
code = secrets.token_urlsafe(32)
|
||||||
|
expires = parse_expiry(logged.expires_at)
|
||||||
|
self.codes[code] = _Code(
|
||||||
|
code=code,
|
||||||
|
client_id=pending.client_id,
|
||||||
|
params=pending.params,
|
||||||
|
api_token=api_token,
|
||||||
|
expires_at=int(expires.timestamp()) if expires else None,
|
||||||
|
subject=logged.username,
|
||||||
|
)
|
||||||
|
target = construct_redirect_uri(
|
||||||
|
str(pending.params.redirect_uri),
|
||||||
|
code=code,
|
||||||
|
state=pending.params.state,
|
||||||
|
)
|
||||||
|
return RedirectResponse(target, status_code=302)
|
||||||
|
|
||||||
|
def _page(self, txn: str, error: str) -> HTMLResponse:
|
||||||
|
block = f'<p class="error">{html.escape(error)}</p>' if error else ""
|
||||||
|
body = _PAGE.format(error=block, txn=html.escape(txn))
|
||||||
|
return HTMLResponse(body)
|
||||||
|
|
@ -4,9 +4,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
|
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||||
from mcp.server.mcpserver.exceptions import ToolError
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
|
from pydantic import AnyHttpUrl
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from verae_mcp_timestamping_server.global_client import (
|
from verae_mcp_timestamping_server.global_client import (
|
||||||
CreateUserResult,
|
CreateUserResult,
|
||||||
|
|
@ -14,7 +21,10 @@ from verae_mcp_timestamping_server.global_client import (
|
||||||
GlobalLedger,
|
GlobalLedger,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
SessionResult,
|
SessionResult,
|
||||||
|
request_token,
|
||||||
)
|
)
|
||||||
|
from verae_mcp_timestamping_server.oauth_login import VeraeOAuthProvider
|
||||||
|
from verae_mcp_timestamping_server.setup_cmd import print_login_hint, run_session, run_setup
|
||||||
from verae_mcp_timestamping_server.guard import reject_content_keys, strict_arg_model
|
from verae_mcp_timestamping_server.guard import reject_content_keys, strict_arg_model
|
||||||
from verae_mcp_timestamping_server.ledger import (
|
from verae_mcp_timestamping_server.ledger import (
|
||||||
FileLedger,
|
FileLedger,
|
||||||
|
|
@ -25,9 +35,41 @@ from verae_mcp_timestamping_server.ledger import (
|
||||||
VerifyResult,
|
VerifyResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
mcp = MCPServer("verae-timestamping")
|
mcp = MCPServer(
|
||||||
|
"verae-timestamping",
|
||||||
|
instructions=(
|
||||||
|
"Global stamps need a VeraeTime login on the machine that runs this server, "
|
||||||
|
"or a bearer token from the browser signup on the public server. "
|
||||||
|
"If session is not valid, ask the user to run "
|
||||||
|
"`uvx verae-mcp-timestamping-server setup`. "
|
||||||
|
"Do not ask them to paste a token into the chat or into mcp.json. "
|
||||||
|
"The token file is ~/.verae/auth.properties, mode 600."
|
||||||
|
),
|
||||||
|
)
|
||||||
_ledger: Ledger = MemoryLedger()
|
_ledger: Ledger = MemoryLedger()
|
||||||
_global_client: GlobalClient = GlobalClient()
|
_global_client: GlobalClient = GlobalClient()
|
||||||
|
_oauth: VeraeOAuthProvider | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _caller_token():
|
||||||
|
access = get_access_token()
|
||||||
|
if access is None or not access.token:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
reset = request_token.set(access.token)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
request_token.reset(reset)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.custom_route("/account", methods=["GET", "POST"])
|
||||||
|
async def account_page(request: Request) -> Response:
|
||||||
|
"""Browser signup and login. The JWT is handed to the assistant, not printed."""
|
||||||
|
if _oauth is None:
|
||||||
|
return Response("OAuth is not enabled on this server.", status_code=404)
|
||||||
|
return await _oauth.handle_account(request)
|
||||||
|
|
||||||
|
|
||||||
def default_ledger(client: GlobalClient | None = None) -> Ledger:
|
def default_ledger(client: GlobalClient | None = None) -> Ledger:
|
||||||
|
|
@ -100,6 +142,7 @@ async def stamp(
|
||||||
) -> Receipts:
|
) -> Receipts:
|
||||||
"""Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID."""
|
"""Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID."""
|
||||||
try:
|
try:
|
||||||
|
with _caller_token():
|
||||||
receipts = await _ledger.stamp(
|
receipts = await _ledger.stamp(
|
||||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
||||||
)
|
)
|
||||||
|
|
@ -111,6 +154,7 @@ async def stamp(
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult:
|
async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult:
|
||||||
"""Check a receipt. want_scope=global asks api.veraetime.net (central source of truth)."""
|
"""Check a receipt. want_scope=global asks api.veraetime.net (central source of truth)."""
|
||||||
|
with _caller_token():
|
||||||
return await _ledger.verify(receipt_id, want_scope=want_scope)
|
return await _ledger.verify(receipt_id, want_scope=want_scope)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -163,9 +207,37 @@ async def hash_report(
|
||||||
return Receipts(receipts=receipts)
|
return Receipts(receipts=receipts)
|
||||||
|
|
||||||
|
|
||||||
|
def enable_oauth(public_url: str) -> VeraeOAuthProvider:
|
||||||
|
"""Turn on browser signup for the HTTP server. Stdio stays open unless this runs."""
|
||||||
|
global _oauth
|
||||||
|
issuer = public_url.rstrip("/")
|
||||||
|
provider = VeraeOAuthProvider(api=_global_client, issuer=issuer)
|
||||||
|
mcp.settings.auth = AuthSettings(
|
||||||
|
issuer_url=AnyHttpUrl(issuer),
|
||||||
|
resource_server_url=AnyHttpUrl(issuer + "/mcp"),
|
||||||
|
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["stamp"]),
|
||||||
|
required_scopes=["stamp"],
|
||||||
|
validate_token_resource=False,
|
||||||
|
)
|
||||||
|
mcp._auth_server_provider = provider
|
||||||
|
from mcp.server.auth.provider import ProviderTokenVerifier
|
||||||
|
|
||||||
|
mcp._token_verifier = ProviderTokenVerifier(provider)
|
||||||
|
_oauth = provider
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "setup":
|
||||||
|
raise SystemExit(run_setup(sys.argv[2:]))
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "session":
|
||||||
|
raise SystemExit(run_session())
|
||||||
client = GlobalClient.from_env()
|
client = GlobalClient.from_env()
|
||||||
build_mcp(default_ledger(client), global_client=client)
|
build_mcp(default_ledger(client), global_client=client)
|
||||||
|
print_login_hint(client)
|
||||||
|
if os.environ.get("VERAE_MCP_OAUTH") == "1":
|
||||||
|
public = os.environ.get("VERAE_MCP_PUBLIC_URL", "http://127.0.0.1:8000")
|
||||||
|
enable_oauth(public)
|
||||||
transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
|
transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
|
||||||
if transport == "streamable-http":
|
if transport == "streamable-http":
|
||||||
host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1")
|
host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1")
|
||||||
|
|
|
||||||
96
src/verae_mcp_timestamping_server/setup_cmd.py
Normal file
96
src/verae_mcp_timestamping_server/setup_cmd.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""First-run registration for a machine that runs this MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from verae_mcp_timestamping_server.global_client import (
|
||||||
|
GlobalClient,
|
||||||
|
default_auth_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def login_hint(client: GlobalClient) -> str | None:
|
||||||
|
"""Text for a machine with no stored VeraeTime login. None when one exists."""
|
||||||
|
if client.token or client._username:
|
||||||
|
return None
|
||||||
|
path = client.auth_path or default_auth_path()
|
||||||
|
return (
|
||||||
|
"No VeraeTime login on this machine.\n"
|
||||||
|
" uvx verae-mcp-timestamping-server setup\n"
|
||||||
|
f"Token file: {path} (mode 600)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_login_hint(client: GlobalClient) -> None:
|
||||||
|
text = login_hint(client)
|
||||||
|
if text:
|
||||||
|
print(text, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_session(client: GlobalClient) -> int:
|
||||||
|
seen = client.session()
|
||||||
|
path = client.auth_path or default_auth_path()
|
||||||
|
print(f"Wrote {path} (mode 600)")
|
||||||
|
print(f"username={seen.username}")
|
||||||
|
print(f"role={seen.role}")
|
||||||
|
print(f"expires_at={seen.expires_at}")
|
||||||
|
print(f"valid={str(seen.valid).lower()}")
|
||||||
|
return 0 if seen.valid else 1
|
||||||
|
|
||||||
|
|
||||||
|
def run_session(client: GlobalClient | None = None) -> int:
|
||||||
|
held = client or GlobalClient.from_env()
|
||||||
|
seen = held.session()
|
||||||
|
print(f"valid={str(seen.valid).lower()}")
|
||||||
|
print(f"username={seen.username}")
|
||||||
|
print(f"role={seen.role}")
|
||||||
|
print(f"expires_at={seen.expires_at}")
|
||||||
|
return 0 if seen.valid else 1
|
||||||
|
|
||||||
|
|
||||||
|
def run_setup(
|
||||||
|
argv: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
client: GlobalClient | None = None,
|
||||||
|
input_fn: Callable[[str], str] = input,
|
||||||
|
password_fn: Callable[[str], str] = getpass.getpass,
|
||||||
|
) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="verae-mcp-timestamping-server setup",
|
||||||
|
description="Create a VeraeTime account or log in, and store the token on this machine.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--username", default="")
|
||||||
|
parser.add_argument("--password", default="")
|
||||||
|
parser.add_argument(
|
||||||
|
"--signup",
|
||||||
|
action="store_true",
|
||||||
|
help="Create a new user account, then log in.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
username = args.username.strip()
|
||||||
|
password = args.password
|
||||||
|
signup = args.signup
|
||||||
|
if not username or not password:
|
||||||
|
choice = input_fn("Account [new/existing] (new): ").strip().lower()
|
||||||
|
signup = choice in ("", "new", "signup", "register")
|
||||||
|
if not username:
|
||||||
|
username = input_fn("Username: ").strip()
|
||||||
|
if not password:
|
||||||
|
password = password_fn("Password: ")
|
||||||
|
if not username or not password:
|
||||||
|
print("username and password are required", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
held = client or GlobalClient.from_env()
|
||||||
|
try:
|
||||||
|
if signup:
|
||||||
|
created = held.signup(username, password)
|
||||||
|
print(f"created username={created.username} role={created.role}")
|
||||||
|
held.login(username, password)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return _print_session(held)
|
||||||
102
tests/test_oauth_login.py
Normal file
102
tests/test_oauth_login.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Browser account page issues a VeraeTime JWT without storing it on the host."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp.server.auth.provider import AuthorizationParams
|
||||||
|
from mcp.shared.auth import OAuthClientInformationFull
|
||||||
|
from pydantic import AnyUrl
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from verae_mcp_timestamping_server.global_client import GlobalClient, request_token
|
||||||
|
from verae_mcp_timestamping_server.ledger import SHA256_EMPTY
|
||||||
|
from verae_mcp_timestamping_server.oauth_login import VeraeOAuthProvider
|
||||||
|
|
||||||
|
from test_global_client import _auth_http
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
def _request(method: str, body: bytes = b"", query: bytes = b"") -> Request:
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": method,
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/account",
|
||||||
|
"raw_path": b"/account",
|
||||||
|
"query_string": query,
|
||||||
|
"headers": [(b"content-type", b"application/x-www-form-urlencoded")],
|
||||||
|
"client": ("127.0.0.1", 1234),
|
||||||
|
"server": ("127.0.0.1", 8000),
|
||||||
|
}
|
||||||
|
return Request(scope, receive)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_account_form_issues_code_and_leaves_host_token(tmp_path):
|
||||||
|
http = _auth_http()
|
||||||
|
api = GlobalClient(
|
||||||
|
"https://api.veraetime.net",
|
||||||
|
token="host-token",
|
||||||
|
http=http,
|
||||||
|
auth_path=tmp_path / "auth.properties",
|
||||||
|
)
|
||||||
|
provider = VeraeOAuthProvider(api, "http://127.0.0.1:8000")
|
||||||
|
client = OAuthClientInformationFull(
|
||||||
|
client_id="assistant",
|
||||||
|
redirect_uris=[AnyUrl("http://127.0.0.1/callback")],
|
||||||
|
)
|
||||||
|
await provider.register_client(client)
|
||||||
|
params = AuthorizationParams(
|
||||||
|
state="st",
|
||||||
|
scopes=["stamp"],
|
||||||
|
code_challenge="challenge",
|
||||||
|
redirect_uri=AnyUrl("http://127.0.0.1/callback"),
|
||||||
|
redirect_uri_provided_explicitly=True,
|
||||||
|
)
|
||||||
|
url = await provider.authorize(client, params)
|
||||||
|
txn = url.split("txn=", 1)[1]
|
||||||
|
response = await provider.handle_account(
|
||||||
|
_request(
|
||||||
|
"POST",
|
||||||
|
body=f"txn={txn}&username=newcomer&password=secret-pass&mode=signup".encode(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
location = response.headers["location"]
|
||||||
|
assert location.startswith("http://127.0.0.1/callback?")
|
||||||
|
assert "code=" in location
|
||||||
|
assert "fresh-token" not in location
|
||||||
|
assert api.token == "host-token"
|
||||||
|
assert not (tmp_path / "auth.properties").exists()
|
||||||
|
code = location.split("code=", 1)[1].split("&", 1)[0]
|
||||||
|
loaded = await provider.load_authorization_code(client, code)
|
||||||
|
assert loaded is not None
|
||||||
|
issued = await provider.exchange_authorization_code(client, loaded)
|
||||||
|
assert issued.access_token == "fresh-token"
|
||||||
|
assert issued.refresh_token
|
||||||
|
seen = await provider.load_access_token("fresh-token")
|
||||||
|
assert seen is not None
|
||||||
|
assert seen.subject == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_token_does_not_refresh_host_login(tmp_path):
|
||||||
|
path = tmp_path / "auth.properties"
|
||||||
|
path.write_text(
|
||||||
|
"access_token=old-token\n"
|
||||||
|
"expires_at=2020-01-01T00:00:00Z\n"
|
||||||
|
"username=admin\n"
|
||||||
|
"password=secret-pass\n"
|
||||||
|
)
|
||||||
|
http = _auth_http()
|
||||||
|
client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path)
|
||||||
|
reset = request_token.set("caller-token")
|
||||||
|
try:
|
||||||
|
rec = client.submit(SHA256_EMPTY)
|
||||||
|
finally:
|
||||||
|
request_token.reset(reset)
|
||||||
|
assert rec.receipt_id == "QmFresh"
|
||||||
|
assert http.calls[0][1].endswith("/api/timestamp")
|
||||||
|
assert http.calls[0][3]["Authorization"] == "Bearer caller-token"
|
||||||
|
assert client.token == "old-token"
|
||||||
39
tests/test_setup.py
Normal file
39
tests/test_setup.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""First-run setup stores a login and does not print the JWT."""
|
||||||
|
|
||||||
|
from verae_mcp_timestamping_server.global_client import GlobalClient
|
||||||
|
from verae_mcp_timestamping_server.setup_cmd import login_hint, run_setup
|
||||||
|
|
||||||
|
from test_global_client import FakeHttp, _auth_http
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_signup_writes_token_file_without_printing_it(tmp_path, capsys):
|
||||||
|
path = tmp_path / "auth.properties"
|
||||||
|
http = _auth_http()
|
||||||
|
client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path)
|
||||||
|
code = run_setup(
|
||||||
|
["--signup", "--username", "newcomer", "--password", "secret-pass"],
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
text = capsys.readouterr().out
|
||||||
|
assert "fresh-token" not in text
|
||||||
|
assert "username=admin" in text
|
||||||
|
assert "role=admin" in text
|
||||||
|
assert f"Wrote {path}" in text
|
||||||
|
stored = path.read_text()
|
||||||
|
assert "access_token=fresh-token" in stored
|
||||||
|
assert path.stat().st_mode & 0o777 == 0o600
|
||||||
|
assert any(url.endswith("/auth/signup") for _, url, _, _ in http.calls)
|
||||||
|
assert any(url.endswith("/auth/login") for _, url, _, _ in http.calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_hint_only_when_missing(tmp_path):
|
||||||
|
empty = GlobalClient("https://api.veraetime.net", http=FakeHttp(), auth_path=tmp_path / "missing.properties")
|
||||||
|
assert "uvx verae-mcp-timestamping-server setup" in (login_hint(empty) or "")
|
||||||
|
signed = GlobalClient(
|
||||||
|
"https://api.veraetime.net",
|
||||||
|
token="secret",
|
||||||
|
http=FakeHttp(),
|
||||||
|
auth_path=tmp_path / "auth.properties",
|
||||||
|
)
|
||||||
|
assert login_hint(signed) is None
|
||||||
Loading…
Reference in a new issue