Lesson 25 of 30, Module 4: Ship It. It publishes to Substack, runs on the web, and anyone can install it.
What you can do after this lesson
A new service costs the same 5 steps as any other tool, plus a small extra bill that belongs to the service and not to the tool: one package and two settings here. Because the glue lives inside your server, every app that already talks to Scribe gets the new service for free.
The problem we inherited
The draft is finished in Drive and still goes nowhere. You copy it into Substack by hand, which is exactly where you came in.
The 5 steps this lesson runs
- Write the plain Python function
- Put the @mcp line above it
- Write the one-line docstring
- Restart Claude Desktop
- Ask Claude to use it
Code in this lesson
Every block the lesson shows on screen, in the order it appears.
1. Write the plain Python function
2. Put the @mcp line above it
3. Write the one-line docstring
4. Restart Claude Desktop
5. Ask Claude to use it
uv add requests
import requests
SUBSTACK = os.environ.get("SCRIBE_SUBSTACK", "")
COOKIE = os.environ.get("SCRIBE_SUBSTACK_COOKIE", "")
def _substack(pub: str) -> str:
return f"https://{pub}.substack.com/api/v1"
def latest_posts(publication: str, count: int = 5) -> str:
r = requests.get(f"{_substack(publication)}/archive",
params={"sort": "new", "limit": count},
timeout=30)
if r.status_code != 200:
raise ToolError(f"No Substack answers at {publication}.")
return "\n".join(f"{p['post_date'][:10]} {p['title']}\n"
f" {p['canonical_url']}"
for p in r.json())
@mcp.tool()
def latest_posts(publication: str, count: int = 5) -> str:
"""The newest posts on any Substack. Public, no login."""
r = requests.get(f"{_substack(publication)}/archive",
params={"sort": "new", "limit": count},
timeout=30)
if r.status_code != 200:
raise ToolError(f"No Substack answers at {publication}.")
return "\n".join(f"{p['post_date'][:10]} {p['title']}\n"
f" {p['canonical_url']}"
for p in r.json())
def _signed_in() -> requests.Session:
"""A session with your cookie. The cookie stays in the env."""
if not SUBSTACK or not COOKIE:
raise ToolError(
"Scribe can read any Substack, but writing to yours"
" needs SCRIBE_SUBSTACK and SCRIBE_SUBSTACK_COOKIE.")
s = requests.Session()
s.cookies.set("substack.sid", COOKIE, domain=".substack.com")
return s
def _me(s: requests.Session) -> int:
"""Your user id. Substack wants it on every new draft."""
r = s.get(f"{_substack(SUBSTACK)}/publication_user",
timeout=30)
if r.status_code != 200:
raise ToolError("Substack did not recognise the cookie."
" Copy a fresh substack.sid from your"
" browser and set it again.")
users = r.json().get("pub_users", [])
return next(u["user_id"] for u in users if u["is_primary"])
def _blocks(text: str) -> list:
"""Markdown headings and paragraphs, in Substack's shape."""
blocks = []
for part in text.strip().split("\n\n"):
part = part.strip()
if not part:
continue
if part.startswith("#"):
level = len(part) - len(part.lstrip("#"))
words = part.strip("# ")
blocks.append({"type": "heading",
"attrs": {"level": level},
"content": [{"type": "text",
"text": words}]})
else:
blocks.append({"type": "paragraph",
"content": [{"type": "text",
"text": " ".join(
part.split())}]})
return blocks
def publish_draft(name: str) -> str:
text = _text(_find(name))
title, body = name, text
if text.startswith("# "):
title, _, body = text.partition("\n")
title = title[2:].strip()
s = _signed_in()
draft = {"draft_title": title,
"draft_body": json.dumps({"type": "doc",
"content": _blocks(body)}),
"draft_bylines": [{"id": _me(s), "is_guest": False}],
"audience": "everyone", "type": "newsletter"}
r = s.post(f"{_substack(SUBSTACK)}/drafts", json=draft,
timeout=30)
if r.status_code != 200:
raise ToolError(f"Substack said no ({r.status_code}):"
f" {r.text[:120]}")
return (f"Draft made from {name}. Open it, read it, then"
f" publish it yourself: https://{SUBSTACK}"
f".substack.com/publish/post/{r.json()['id']}")
@mcp.tool()
def publish_draft(name: str) -> str:
"""Send one Drive draft to your Substack as a DRAFT post.
It never publishes. The first '# ' line becomes the title.
You open the draft in Substack and press publish yourself."""
text = _text(_find(name))
title, body = name, text
if text.startswith("# "):
title, _, body = text.partition("\n")
title = title[2:].strip()
s = _signed_in()
draft = {"draft_title": title,
"draft_body": json.dumps({"type": "doc",
"content": _blocks(body)}),
"draft_bylines": [{"id": _me(s), "is_guest": False}],
"audience": "everyone", "type": "newsletter"}
r = s.post(f"{_substack(SUBSTACK)}/drafts", json=draft,
timeout=30)
if r.status_code != 200:
raise ToolError(f"Substack said no ({r.status_code}):"
f" {r.text[:120]}")
return (f"Draft made from {name}. Open it, read it, then"
f" publish it yourself: https://{SUBSTACK}"
f".substack.com/publish/post/{r.json()['id']}")
"env": {
"SCRIBE_FOLDER_ID": "paste your folder id here",
"SCRIBE_KEY": "/FULL/PATH/TO/scribe/key.json",
"SCRIBE_SUBSTACK": "your-publication-name",
"SCRIBE_SUBSTACK_COOKIE": "paste the substack.sid value here"
}
Use scribe to show me the 3 newest posts on genaiunplugged.
Now the 3 newest on platformer.
Downloads
- Module 4 checkpoint (scribe-checkpoint-m4.zip): working code as the module leaves it, so a broken session costs you nothing
- The course GitHub repo: every checkpoint, the README and the full lesson list
Lesson transcript
Scribe learns a second service
Today Scribe learns a whole new service. A different company, a different web address, a different login. And I run the same 5 steps, and I do not cut the clock.
Hello, and welcome to lesson 25 of the MCP Masterclass. Last lesson we named the last manual step, and the promise from lesson 5. Today I pay it. And we start with our map, as always.
13 lit boxes. By the end of this lesson publish draft lights up, at the bottom of the tool column, and Scribe talks to 2 services instead of 1.
Can I do this without a Substack?
Well, before a line of code, I want to split this lesson in 2, so nobody finds out halfway that they cannot finish.
Substack has a read half and a write half. Reading the newest posts on any Substack is public. No account, no key, no cookie. Everybody can build and run that tool today.
Writing a draft into your Substack needs 2 things. A Substack of your own, and 1 cookie from your browser. If you do not have a Substack, build the write tool anyway. It tells you what it needs, in a sentence, and it waits.
The same 5 steps, run 5
Okay. You know the list. Here it is, and it has not changed by a word.
1. Write the plain Python function
2. Put the @mcp line above it
3. Write the one-line docstring
4. Restart Claude Desktop
5. Ask Claude to use it
Write the plain Python function. Put the @mcp line above it. Write the one line docstring. Restart Claude Desktop. Ask Claude to use it.
This is run number 5. And this one is the scale proof. Every run so far added a tool that talked to the same 2 places, your Drive and your memory file. This run adds a service that has never heard of either.
What sits outside the steps
Now, 2 things come before step 1, and I want to be honest that they are outside the 5 steps. They are the cost of a second service, not the cost of a tool.
First, 1 new package.
uv add requests
Requests is the package Python people use to talk to a web address. And I want to tell you something I hit while building this.
It was already on my machine. One of the Google libraries had brought it along, and my first draft of this lesson imported it with no line in the requirements file. It worked, by accident. A package you did not ask for can leave the same way. So you ask for it, and the requirements file gets a fourth line.
Second, 2 new settings. Here they are, at the top of scribe_server.py, under the memory line.
import requests
SUBSTACK = os.environ.get("SCRIBE_SUBSTACK", "")
COOKIE = os.environ.get("SCRIBE_SUBSTACK_COOKIE", "")
The first is the name of your Substack. The part before dot substack dot com. The second is the cookie, and both start out empty, so the read half works with neither one set.
The read tool
Okay. The read tool. Step 1. Write the plain Python function.
def _substack(pub: str) -> str:
return f"https://{pub}.substack.com/api/v1"
def latest_posts(publication: str, count: int = 5) -> str:
r = requests.get(f"{_substack(publication)}/archive",
params={"sort": "new", "limit": count},
timeout=30)
if r.status_code != 200:
raise ToolError(f"No Substack answers at {publication}.")
return "\n".join(f"{p['post_date'][:10]} {p['title']}\n"
f" {p['canonical_url']}"
for p in r.json())
Two small pieces. The helper builds the web address of a Substack's API from its name. Every Substack has the same one, so 1 line covers all of them.
And latest posts asks that address for its archive, newest first, and how many you want. 200 is the number a web server uses for yes. Anything else, and Scribe says no Substack answers at that name, in a sentence, the lesson 13 way.
Then it gives back 1 line per post. The date, the title, and the link.
Step 2. Put the @mcp line above it. Step 3. Write the one line docstring.
@mcp.tool()
def latest_posts(publication: str, count: int = 5) -> str:
"""The newest posts on any Substack. Public, no login."""
r = requests.get(f"{_substack(publication)}/archive",
params={"sort": "new", "limit": count},
timeout=30)
if r.status_code != 200:
raise ToolError(f"No Substack answers at {publication}.")
return "\n".join(f"{p['post_date'][:10]} {p['title']}\n"
f" {p['canonical_url']}"
for p in r.json())
The docstring says public, no login, because that is what Claude needs to know to reach for it. That is the read tool done. 3 steps, and we have not restarted yet, because the write tool comes first.
Finding the cookie and keeping it safe
Now the write tool. And before the function, the cookie, because the function is useless without it.
When you log in to Substack, your browser keeps a small value that proves it is you. Substack calls it substack dot s i d. Here is how you find it.
Open Substack in Chrome, logged in. Open the developer tools. Go to the Application tab, then Cookies, then substack dot com. Find the row called substack dot s i d, and copy its value. It is long, and it starts with the letter s.
On screen: Chrome DevTools, Application tab, Cookies, https://substack.com, the row substack.sid with its value blurred.
One thing I hit here. My own notes for this course said the cookie was called connect dot s i d. That was its old name. The row you will see is substack dot s i d, and Substack still answers to the old name too. Teach what you see, not what you wrote down.
Now the rules for that value, because it is a password.
It goes in 1 place, the setting called scribe substack cookie. Scribe reads it once when it starts. It never prints it, it never writes it into memory.json, and it never puts it in a tool result, so Claude never sees it either. And it lives about 30 days, so one day it will stop working, and Scribe has a sentence ready for that day.
The write tool and the 400 error
Okay. Step 1 for the write tool. 3 helpers first.
def _signed_in() -> requests.Session:
"""A session with your cookie. The cookie stays in the env."""
if not SUBSTACK or not COOKIE:
raise ToolError(
"Scribe can read any Substack, but writing to yours"
" needs SCRIBE_SUBSTACK and SCRIBE_SUBSTACK_COOKIE.")
s = requests.Session()
s.cookies.set("substack.sid", COOKIE, domain=".substack.com")
return s
def _me(s: requests.Session) -> int:
"""Your user id. Substack wants it on every new draft."""
r = s.get(f"{_substack(SUBSTACK)}/publication_user",
timeout=30)
if r.status_code != 200:
raise ToolError("Substack did not recognise the cookie."
" Copy a fresh substack.sid from your"
" browser and set it again.")
users = r.json().get("pub_users", [])
return next(u["user_id"] for u in users if u["is_primary"])
Signed in builds a session that carries your cookie. If either setting is empty, it refuses with a sentence that names both. That is the sentence you will see if you have no Substack, and it is a sentence, not a crash.
Me asks Substack who you are, and gives back your user id. And if Substack does not accept the cookie, that is the sentence for the day it dies. Copy a fresh one, set it again.
Now, why do I need my own user id to make a draft? I did not know I did. Here is what happened.
I sent Substack a draft with 4 fields. A title, a body, who can read it, and the type. And Substack said 400, which means you sent me something wrong, and it named the field. Draft bylines. Substack refuses a draft with no author on it, even when it knows who you are.
On screen: the 400 reply, {"errors":[{"location":"body","param":"draft_bylines","msg":"Invalid value"}]}, then the same call with draft_bylines added, HTTP 200.
So the write is 3 calls, not 1. Ask who I am. Make the draft. And in the proof script, delete it again. That is the thing about a second service. The first service never asked for an author.
The third helper turns your draft into the shape Substack wants.
def _blocks(text: str) -> list:
"""Markdown headings and paragraphs, in Substack's shape."""
blocks = []
for part in text.strip().split("\n\n"):
part = part.strip()
if not part:
continue
if part.startswith("#"):
level = len(part) - len(part.lstrip("#"))
words = part.strip("# ")
blocks.append({"type": "heading",
"attrs": {"level": level},
"content": [{"type": "text",
"text": words}]})
else:
blocks.append({"type": "paragraph",
"content": [{"type": "text",
"text": " ".join(
part.split())}]})
return blocks
Substack does not take plain text. Its editor stores a post as a list of blocks, each one a heading or a paragraph. So blocks splits your draft on blank lines. A part that starts with a hash becomes a heading, and the number of hashes is its level. Anything else becomes a paragraph.
And I want to be straight about what it does not do. Bold, links and lists are not turned into anything. They arrive as plain text, and you fix them in the editor. Doing that properly took me hundreds of lines in another project, and it is not what this lesson is about.
Now the function itself.
def publish_draft(name: str) -> str:
text = _text(_find(name))
title, body = name, text
if text.startswith("# "):
title, _, body = text.partition("\n")
title = title[2:].strip()
s = _signed_in()
draft = {"draft_title": title,
"draft_body": json.dumps({"type": "doc",
"content": _blocks(body)}),
"draft_bylines": [{"id": _me(s), "is_guest": False}],
"audience": "everyone", "type": "newsletter"}
r = s.post(f"{_substack(SUBSTACK)}/drafts", json=draft,
timeout=30)
if r.status_code != 200:
raise ToolError(f"Substack said no ({r.status_code}):"
f" {r.text[:120]}")
return (f"Draft made from {name}. Open it, read it, then"
f" publish it yourself: https://{SUBSTACK}"
f".substack.com/publish/post/{r.json()['id']}")
Read it top to bottom. It reads the draft out of Drive, through the fence and the wrapper from lesson 17, so a bad name still gets the same sentence. If the first line starts with a hash and a space, that line is the title, and it comes out of the body, because Substack shows the title itself.
Then it signs in, builds the draft with your title, your blocks, and you as the author, and sends it. If Substack says anything but yes, you get the number and the first few words of why.
And look at the last line. It does not say published. It says, draft made, open it, read it, then publish it yourself. And it hands you the link to that draft.
Step 2. The @mcp line. Step 3. The docstring.
@mcp.tool()
def publish_draft(name: str) -> str:
"""Send one Drive draft to your Substack as a DRAFT post.
It never publishes. The first '# ' line becomes the title.
You open the draft in Substack and press publish yourself."""
text = _text(_find(name))
title, body = name, text
if text.startswith("# "):
title, _, body = text.partition("\n")
title = title[2:].strip()
s = _signed_in()
draft = {"draft_title": title,
"draft_body": json.dumps({"type": "doc",
"content": _blocks(body)}),
"draft_bylines": [{"id": _me(s), "is_guest": False}],
"audience": "everyone", "type": "newsletter"}
r = s.post(f"{_substack(SUBSTACK)}/drafts", json=draft,
timeout=30)
if r.status_code != 200:
raise ToolError(f"Substack said no ({r.status_code}):"
f" {r.text[:120]}")
return (f"Draft made from {name}. Open it, read it, then"
f" publish it yourself: https://{SUBSTACK}"
f".substack.com/publish/post/{r.json()['id']}")
This docstring has 3 lines, and the first one does the job. It never publishes. That is what Claude reads, so Claude never promises you a published post.
Restart and ask Claude
Step 4. Restart Claude Desktop. But first, the 2 settings go into the same place as your folder id and your key, in the settings file from lesson 12.
"env": {
"SCRIBE_FOLDER_ID": "paste your folder id here",
"SCRIBE_KEY": "/FULL/PATH/TO/scribe/key.json",
"SCRIBE_SUBSTACK": "your-publication-name",
"SCRIBE_SUBSTACK_COOKIE": "paste the substack.sid value here"
}
Two new lines. Your Substack's name, and the cookie. Nothing else in the file changes. Quit Claude Desktop fully, and open it again.
Step 5. Ask Claude to use it.
Use scribe to show me the 3 newest posts on genaiunplugged.
On screen: Claude Desktop calls latest_posts with publication genaiunplugged and count 3. Three dated titles, each with a link.
Three posts, dated, with links. That is my own Substack. Now one that is not mine.
Now the 3 newest on platformer.
On screen: Claude calls latest_posts with publication platformer. Three dated titles with links. Hold on this answer.
Same tool, somebody else's Substack, and no cookie was involved anywhere. That is the read half, and it is free.
The write half gets the whole of the next lesson, from the typo in Drive to the Publish button, because I want you to see every step of it.
What it cost and what did not change
So what did the 5 steps cost you this time?
Step 1 was 4 helpers and 2 functions. Step 2 was 2 lines. Step 3 was 2 docstrings. Step 4 was a restart, with 2 settings added first. Step 5 you have done 4 times before.
And outside the steps, 1 package and 2 settings. That is the price of a second service, and now you know it exactly.
Now look at what did not change. Claude Desktop learned nothing new. The second app from lesson 6 learned nothing new. The pipe did not change. The list did not change by a word.
That is reason number 2, paid in full. Every app that already talks to Scribe got Substack for free, because the glue was written once, inside your server.
The map: 14 lit
So where does our map stand at the end of this lesson?
14 boxes lit. Publish draft is on, at the bottom of the tool column, and it reaches out past your Drive to a second service.
Next lesson a draft goes all the way. From Drive, through the guard, into Substack, and up to the button you press yourself.
Bye now, and I will see you in the next lesson.