Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Sentou ships an MCP server, so you can publish without leaving a Claude session.
claude mcp add sentou -- npx tsx "$(pwd)/mcp/server.ts"
```

Claude gets two tools, `publish_artifact(html)` and `republish(id, html)`. If your instance is not on localhost, or you need to authenticate, pass both through the MCP server's environment, otherwise a hardened instance answers the publish call with a bare `401`. Generate a key from the Account screen or with `POST /api/keys` (the key value is returned once):
Claude gets two tools, `publish_artifact` and `republish(id, html)`. `publish_artifact` takes the HTML plus optional gating and tracking, so you can ask for a gated, tracked link in one step ("publish this, require a verified email from acme.com, and track opens"): `title`, `requireEmail`, `verifyEmail`, `allowedDomains`, `expiresAt`, and `track`. With none of them set the link is public, untracked, and untitled. `republish` swaps in new HTML on an existing link and leaves its gate untouched. If your instance is not on localhost, or you need to authenticate, pass both through the MCP server's environment, otherwise a hardened instance answers the publish call with a bare `401`. Generate a key from the Account screen or with `POST /api/keys` (the key value is returned once):

```bash
claude mcp add sentou \
Expand Down
35 changes: 35 additions & 0 deletions mcp/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,41 @@ describe("mcp http client", () => {
);
});

it("forwards gate + tracking + title options into the publish body", async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ id: "a", slug: "s", url: "http://x/v/s", version: 1 })),
);
vi.stubGlobal("fetch", fetchMock);
await publishArtifact("<h1>x</h1>", {
title: "Q3 deck",
verifyEmail: true,
allowedDomains: ["acme.com"],
expiresAt: "2030-01-01T00:00:00.000Z",
track: true,
});
const init = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(init[1].body as string);
expect(body).toMatchObject({
html: "<h1>x</h1>",
title: "Q3 deck",
verifyEmail: true,
allowedDomains: ["acme.com"],
expiresAt: "2030-01-01T00:00:00.000Z",
track: true,
});
});

it("sends only html when no options are given (link stays open + untracked)", async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ id: "a", slug: "s", url: "http://x/v/s", version: 1 })),
);
vi.stubGlobal("fetch", fetchMock);
await publishArtifact("<h1>x</h1>");
const init = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(init[1].body as string);
expect(body).toEqual({ html: "<h1>x</h1>" });
});

it("republishArtifact posts to the republish route and returns the version", async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ id: "a", slug: "s", url: "http://x/v/s", version: 2 })),
Expand Down
23 changes: 21 additions & 2 deletions mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,29 @@ function authHeaders(): Record<string, string> {
return process.env.SENTOU_API_KEY ? { authorization: `Bearer ${process.env.SENTOU_API_KEY}` } : {};
}

export async function publishArtifact(html: string) {
// Optional gate + tracking + title, matching what /api/publish already accepts. Left undefined,
// each falls to the API default (open, untracked, untitled). Only defined fields are sent.
export type PublishOptions = {
title?: string;
requireEmail?: boolean;
verifyEmail?: boolean;
allowedDomains?: string[];
expiresAt?: string;
track?: boolean;
};

export async function publishArtifact(html: string, opts: PublishOptions = {}) {
const body: Record<string, unknown> = { html };
if (opts.title !== undefined) body.title = opts.title;
if (opts.requireEmail !== undefined) body.requireEmail = opts.requireEmail;
if (opts.verifyEmail !== undefined) body.verifyEmail = opts.verifyEmail;
if (opts.allowedDomains !== undefined) body.allowedDomains = opts.allowedDomains;
if (opts.expiresAt !== undefined) body.expiresAt = opts.expiresAt;
if (opts.track !== undefined) body.track = opts.track;

const r = await fetch(`${base()}/api/publish`, {
method: "POST", headers: { "content-type": "application/json", ...authHeaders() },
body: JSON.stringify({ html }),
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`publish failed: ${r.status}`);
return (await r.json()) as { url: string; id: string; version: number };
Expand Down
55 changes: 51 additions & 4 deletions mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,57 @@ const server = new McpServer({ name: "sentou", version: "0.1.0" });

server.registerTool(
"publish_artifact",
{ description: "Publish HTML to a Sentou link and return its URL.", inputSchema: { html: z.string() } },
async ({ html }) => {
const { url, id, version } = await publishArtifact(html);
return { content: [{ type: "text", text: `Published v${version}: ${url} (id: ${id})` }] };
{
description:
"Publish HTML to a Sentou link and return its URL. Optionally gate access (email, domain " +
"allowlist, expiry), turn on per-viewer open tracking, and set a title. With no options the " +
"link is public, untracked, and untitled.",
inputSchema: {
html: z.string().describe("The full HTML document to publish."),
title: z.string().optional().describe("A title shown in the dashboard."),
requireEmail: z
.boolean()
.optional()
.describe("Ask the viewer for an email before showing the artifact."),
verifyEmail: z
.boolean()
.optional()
.describe(
"Require a one-time code sent to the viewer's email (a real lock; needs an email sender " +
"configured on the server). Implies requireEmail.",
),
allowedDomains: z
.array(z.string())
.optional()
.describe("Restrict access to these email domains, e.g. [\"acme.com\"]."),
expiresAt: z
.string()
.optional()
.describe("ISO 8601 date-time after which the link stops working."),
track: z
.boolean()
.optional()
.describe("Record per-viewer opens and dwell time (off by default)."),
},
},
async ({ html, title, requireEmail, verifyEmail, allowedDomains, expiresAt, track }) => {
const { url, id, version } = await publishArtifact(html, {
title,
requireEmail,
verifyEmail,
allowedDomains,
expiresAt,
track,
});
const gate: string[] = [];
if (verifyEmail) gate.push("verified email");
else if (requireEmail) gate.push("email required");
if (allowedDomains?.length) gate.push(`domains: ${allowedDomains.join(", ")}`);
if (expiresAt) gate.push(`expires ${expiresAt}`);
const suffix =
`${gate.length ? ` [gate: ${gate.join("; ")}]` : " [open]"}` +
`${track ? " [tracking on]" : ""}`;
return { content: [{ type: "text", text: `Published v${version}: ${url} (id: ${id})${suffix}` }] };
},
);

Expand Down
Loading