Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
156801 stories
·
33 followers

Introducing the Safari MCP server for web developers

1 Share

In Safari Technology Preview 247, we’re introducing the Safari MCP server — a Model Context Protocol server for web developers that makes your web development and debugging workflow faster and more powerful. We know agents are increasingly integral to the coding process and the Safari MCP server gives your agent the ability to know how your code actually renders in the browser by connecting it to a Safari browser window.

Any MCP-compatible client can connect to the Safari MCP server. By connecting your agent to a Safari browser window, your agent can emulate what your users experience, giving it the information it needs to debug more autonomously, like access to the DOM, network requests, screenshots, and console output.

It speeds up your debugging process and lets you stay in the comfort of your terminal, which means fewer rounds of hopping windows and typing prompts to debug your code.

The use cases

If you build for the web, then you know about the debugging dance. It usually goes something like this:

You see something wrong with your site in the browser. You open the console to hunt it down. You click into the styles tab. You see what’s broken. You go back to your code to fix it. Or maybe you take a screenshot, detail the problem to your agent, and let it do the fixing for you. Hopefully it gets it right, the bug is fixed, and you can move on.

But when it isn’t fixed, you go through the workflow again — Browser. Prompt. Agent.

And again and again, until you finally squash the bug.

Regardless of the browser or tools you use, the debugging workflow is a lot of clicks, tools, and window hopping to make a single fix, but it doesn’t have to be that way. If you’re already using agents in your development workflow, the Safari MCP server makes your debugging faster and more efficient.

The Safari MCP server enables your agent to do more debugging and troubleshooting on its own. Here are just a few examples of what it can help with:

Web development in Safari. The next time you develop in Safari, you’ll benefit from an upgraded workflow. Your agent already helps you with your code, now it can do even more by checking out how your code actually renders in Safari.

Improve compatibility with Safari. Testing in just one browser means missing potential bugs in another, giving those users a subpar experience. With the Safari MCP server, your agent can open your site in Safari, inspect computed styles, check layout, and compare it against what you expect without switching windows.

Analyze performance. See what parts of your site are slowing things down. The Safari MCP server lets your agent evaluate JavaScript on the page to surface performance metrics, like navigation timing and resource load times, so it can pinpoint what’s slowing your site down and work on the right fix.

Check for accessibility. The Safari MCP server lets your agent check for common accessibility issues like missing labels, improper ARIA attributes, and poor contrast, so you can catch problems that impact your users.

Verify any user state. Know that the page is working and looking as it should. Your agent can check the state of the form, query an element using a selector, confirm specific interactions, show different states of a checkout flow, and more. Spend less time on these manual checks and empower the agent to do it for you.

These are just a few of the use cases. However you decide to implement it, the Safari MCP server helps your agent do more for you and reduce all the back and forth that web development often requires. An easier workflow means more bugs squashed, happier users, and a better product.

The tools

Here are the available tools and what they do:

Tool Description
browser_console_messages Return buffered console logs for the current or specified tab
browser_dialogs List and respond to browser dialogs (accept, dismiss, or input text for JS prompts)
close_tab Close a browser tab by its handle
create_tab Create a new browser tab, optionally loading a URL
evaluate_javascript Execute JavaScript code within the page and return the result
get_network_request Get full detail for a single recorded network request (headers, body, timing)
get_page_content Extract text content of a page in various formats (markdown, HTML, JSON, etc.)
list_network_requests List network request summaries (URL, method, status, timing) for the current tab
list_tabs List all open browser tabs with their handles and URLs
navigate_to_url Navigate to a URL and return the loaded page’s content
page_info Get info about the current page: URL, title, and loading state
page_interactions Perform DOM interactions in sequence: click, type, scroll, hover, keyPress, etc.
screenshot Capture a screenshot of the current page as a PNG
set_emulated_media Emulate a CSS media type (e.g. “print”) for responsive-design testing
set_viewport_size Set the browser viewport size in CSS pixels
switch_tab Switch to a different browser tab by its handle
wait_for_navigation Wait for the current page to finish loading; returns final URL and title

With the Safari MCP server, you no longer have to write the perfect prompt, carefully describing to your agent what you’re experiencing in the browser. You can give your agent the ability to find out for itself.

How to get started

First you’ll need to install Safari Technology Preview. Once installed, make sure you enable Safari Settings > Advanced > Show features for web developers. Then go to Safari Settings > Developer > Enable remote automation and external agents.

If you’re using Claude, you can use the following command in your terminal:

claude mcp add safari-mcp-stp -- "/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver" --mcp

If you’re using Codex, you can use the following command in your terminal:

codex mcp add safari-mcp-stp -- "/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver" --mcp

For other agents, you can put the following in your mcp.json or config.json :

"safari-mcp-stp": {
  "command": "/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver",
  "args": ["--mcp"] 
}

Note that while the above code names the server safari-mcp-stp, you can name it whatever you like, even just safari. Once you have the Safari MCP server installed, try one of the following prompts:

Find bugs on my site in Safari
How accessible is my site in Safari?
See how my website performs in Safari

While each agent works a little differently, it shouldn’t need to be told to use the Safari MCP server explicitly — it’ll figure it out on its own. Simple prompts like the ones above are enough to kickstart the MCP.

Here’s how a conversation with an agent using the Safari MCP server might go:

You: Got a bug report — something’s broken in Safari on the flight page. Can you figure out what’s going on?
Agent: Sure, let me take a look.
Agent: I found two distinct bugs on the flight page in Safari. Want me to fix them both?
You: Yes. Anything else here that might be causing problems for Safari users?
Agent: Yes, two more things worth flagging. The results reveal animation is silently broken and the API server startup log is wrong. Should I tackle both as well?

 

All you need is an initial request to get started, and with the help of the Safari MCP server, your agent can take it from there.

The Safari MCP server runs entirely on your local machine and makes no network calls of its own. It also does not have access to your personal information in Safari (e.g. AutoFill or other browser activity). When it captures page content, screenshots, or console logs, that data goes directly to the agent you’re running — not to Apple. What happens to that data from there depends on the agent and model you’re using. As with any agent you give access to your browser, only use ones you trust.

Why we built this

There are many ways to build for the web, both with and without AI. If AI is a part of your workflow, we think this tool will help make it even more productive. And if it isn’t, that’s OK too.

By creating this resource, we hope to make it easier than ever to test and debug in Safari by helping your agent understand how things look and work in the browser.

If you end up giving it a try or if this is your first time using an MCP server, let us know what you think.

Find us online: Saron Yitbarek on BlueSky, Jen Simmons on Bluesky / Mastodon, and Jon Davis on Bluesky / Mastodon. If you run into any issues, file a WebKit bug report. Filing issues really does make a difference.

Read the whole story
alvinashcraft
23 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

The new ngrok.ai

1 Share
The ngrok AI Gateway is now app.ngrok.ai. One key, one URL to access any model, including the ones you run yourself, with access controls and per-call cost visibility.
Read the whole story
alvinashcraft
56 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Can agents be proud of their work?

1 Share
Can agents be proud of their work?
Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Roll your own file-based router in under 50 lines of code

1 Share

Nowadays, web frameworks come with ✨ magic ✨. Some more than others, but all of them have some. The magic is usually in the form of conventions: do things their way and the framework helps you out; wander off the happy path and you're on your own.

One of the main tricks they pull is quietly turning your folder structure into a config file. Create app/about-us/page.tsx in a Next.js app and https://localhost:3000/about-us magically starts working. Rename the file, and the URL changes with it. The framework is reading your file tree and turning it into a routing table.

That is called file-based routing, and it's usually magic your framework either hands you or it doesn't; and when it does, you rarely get to customize it, the conventions are baked in. Want a folder name to carry special meaning, or a logged-in.tsx/logged-out.tsx split per route? Your only option is to post a feature request and wait, or go without.

In Wasp, we really like explicitness, so we've resisted shipping a file-based router. That's changing now, but of course, we're not just adding the magic conventions and moving on. We're giving you a way to choose and design your own magic.

Wait, what's Wasp?

Wasp is a batteries-included full-stack web framework for TypeScript. It covers your frontend, backend, and database as one cohesive whole.

And one of our core ideas is that the entry point to all this full-stack goodness is through an explicit Spec. Instead of hiding your app's structure behind implicit conventions, you declare its features (like routes, authentication, queries, cron jobs, etc) directly in code. Wasp then compiles and stitches these pieces together:

import { app, route, page } from "@wasp.sh/spec";

// `type: ref` means we just want to point to these files, not execute them now:
import MainPage from "./src/pages/MainPage" with { type: "ref" };
import UserPage from "./src/pages/LoginPage" with { type: "ref" };

export default app({
// Some example configurations, we'll skip them in the future:
name: "my-app",
wasp: { version: "^0.24.0" },
auth: { userEntity: "User", google: {} },

// The spec is where you declare your app's features:
spec: [
route("MainRoute", "/", page(MainPage)),
route("LoginRoute", "/login", page(UserPage, { authRequired: true })),
],
});

We chose this approach because explicit code is easier to reason about than hidden magic. This transparency makes the codebase easier to navigate for your team, and it gives AI code assistants a clear, structured map of your application to work with.

That explicitness is the whole philosophy, and it's exactly why we've deliberately never shipped file-based routing. We just didn't want to bake that pile of implicit conventions into the framework.

The Spec is just a program

But here's the part that makes it exciting. We recently updated our spec so that it lives in a main.wasp.ts file. And instead of being a static JSON file that the framework reads, it is just a standard Node.js program, written in TypeScript. You can pull in libraries from npm, read from disk, call an API, use environment variables, anything! The only rule is that you export an app at the end. That's all we need.

So the Spec is explicit, but it's also programmable. And nothing says those route and page calls have to be typed out by hand.

That's the entire trick. You can write a function that walks your src/ folder and returns the same route and page objects you'd otherwise write manually. File-based routing stops being internal magic, and becomes a few lines of ordinary code that live in your repo, that you can read, and more importantly, customize.

So even though we never shipped file-based routing, "we didn't ship it" doesn't mean "you can't have it". You add it yourself, with exactly the conventions you want. You get the best of both worlds, and nobody has to argue about defaults. Sorry if you liked the arguing.

The simplest possible router

Let's build that. Because the route list is just an array of objects, the simplest possible "router" is one we write out by hand:

// The `ref` helper is the dynamic version of `with { type: "ref" }` imports, so we'll use it to build programmatically.
import { app, page, route, ref } from "@wasp.sh/spec";

export default app({
spec: [
route(
"RootRoute",
"/",
page(ref({ importDefault: "RootPage", from: "./src/page.tsx" })),
),
route(
"AboutUsRoute",
"/about-us",
page(
ref({ importDefault: "AboutUsPage", from: "./src/about-us/page.tsx" }),
),
),
],
});

That's a functioning router already, but the unsatisfying part is that we had to type it out ourselves. So let's generate it instead, by looking at the project files.

The step up: generate the routes from the filesystem

We'll pick a very simple convention: a page.tsx inside a folder makes it a route. So src/about-us/page.tsx serves https://my-app.com/about-us, and src/page.tsx serves https://my-app.com/.

Here's the whole thing:

import { page, ref, route } from "@wasp.sh/spec";
import { pascalCase } from "es-toolkit";
import { globSync } from "node:fs";
import * as path from "node:path";

export const fileBasedRoutes = (baseDir: string) => {
return globSync("**/page.tsx", { cwd: baseDir })
.sort() // Make the list stable between runs
.map((filePath) => {
const absoluteFilePath = path.resolve(baseDir, filePath);

const urlRoute = filePath
.split(path.sep)
.slice(0, -1) // Removes the "page.tsx" part
.join("/");

const routeName = pascalCase(urlRoute) || "Root";

return route(
`${routeName}Route`,
"/" + urlRoute,
page(
ref({
importDefault: `${routeName}Page`,
from: absoluteFilePath,
}),
),
);
});
};

That's the entire idea. Find every page.tsx, turn its folder path into a URL path and a name, and produce the route()s. For the route name we can even lean on an npm package to get a clean name; it's just an ordinary npm dependency, like in any Node project.

There's nothing clever going on here, it's plain filesystem glue. An inexperienced dev could write this without much trouble. It never reaches into Wasp's internals, and it only calls the same public route, page, and ref functions you'd use by hand.

Wiring it into your app is one line:

// main.wasp.ts
import { app } from "@wasp.sh/spec";
import { fileBasedRoutes } from "./lib/file-based-routes.wasp";

export default app({
// ...
spec: [
fileBasedRoutes("src"),

// Any other Specs your app uses (job(), query(), action(), ...)
// ...
],
});

fileBasedRoutes returns an array of route objects, and we drop it straight into spec. Voilà, you have file-based routing!

Everything Wasp already does still works

Because we're producing normal route and page spec objects, the rest of Wasp neither knows nor cares that they were generated. So all the features you'd expect keep working, including type-safe links:

import { Link } from "wasp/client/router";

export default function MainPage() {
return (
<>
<h1>Main page</h1>
<Link to="/about-us">About us</Link>
</>
);
}

The <Link> component is fully typechecked with the routes your function generated. Generate a route from a folder, and you will get autocomplete and type errors for it everywhere. Nice.

Now make it yours: route groups

Here's where it gets fun. This is your code, so bend it to your project. Say you want route groups: folders that organize your files without showing up in the URL. The usual convention is to wrap them in parentheses, like (marketing).

It's a one-line filter:

const ROUTE_GROUP_REGEX = /^\(.*\)$/; // Wrapped in parentheses

// ...

const urlRoute = filePath
.split(path.sep)
.slice(0, -1) // Removes the "page.tsx" part
.filter((part) => !ROUTE_GROUP_REGEX.test(part)) // Remove any route groups
.join("/");

Now src/(marketing)/about-us/page.tsx still serves /about-us, but you can keep your marketing pages tidily grouped.

A convention for prerender and auth

Now let's say that in your project, there are two settings you're reaching for constantly: marking a page as auth-required, or marking it for prerendering. Let's invent a convention for both, reusing the route-group syntax we just added: an (auth) group makes everything inside require auth, and a (prerender) group marks pages for prerendering.

We detect those folders and thread the result into the spec objects:

const isPrerender = filePath.includes("(prerender)");
const isAuth = filePath.includes("(auth)");

// ...

return route(
`${routeName}Route`,
"/" + urlRoute,
page(
ref({
importDefault: `${routeName}Page`,
from: absoluteFilePath,
}),
{ authRequired: isAuth },
),
{ prerender: isPrerender },
);

These are your conventions. Don't like parentheses for this? Use a .auth.tsx suffix, a "use auth" directive, or a config file next to the page; whatever reads best for your team. The spec doesn't dictate the shape, you do!

Dynamic and optional segments

Wasp routing also supports dynamic (:id) and optional (:id?) segments. Written out in the spec by hand, they look like this:

route("ProductRoute", "/products/:productId", page(ProductPage)),
route("ArticleRoute", "/articles/:slug?", page(ArticlePage)),

The : character is not allowed in file paths, so let's copy Next.js's bracket syntax for this: [productId] becomes :productId, and [[productId]] becomes :productId?:

const DYNAMIC_SEGMENT_REGEX = /^\[(.*)\]$/; // Wrapped in square brackets
const OPTIONAL_SEGMENT_REGEX = /^\[\[(.*)\]\]$/; // Wrapped in two square brackets

// ...

const urlRoute = filePath
.split(path.sep)
.slice(0, -1) // Removes the "page.tsx" part
.filter((part) => !ROUTE_GROUP_REGEX.test(part)) // Remove any route groups
.map((part) => part.replace(OPTIONAL_SEGMENT_REGEX, ":$1?")) // Convert optional segments
.map((part) => part.replace(DYNAMIC_SEGMENT_REGEX, ":$1")) // Convert dynamic segments
.join("/");

Or you can invent your own syntax. The point is that it's just code, so you do you.

45 lines later

We're done!

import { page, ref, route } from "@wasp.sh/spec";
import { pascalCase } from "es-toolkit";
import { globSync } from "node:fs";
import * as path from "node:path";

const ROUTE_GROUP_REGEX = /^\(.*\)$/; // Wrapped in parentheses
const DYNAMIC_SEGMENT_REGEX = /^\[(.*)\]$/; // Wrapped in square brackets
const OPTIONAL_SEGMENT_REGEX = /^\[\[(.*)\]\]$/; // Wrapped in two square brackets

export const fileBasedRoutes = (baseDir: string) => {
return globSync("**/page.tsx", { cwd: baseDir })
.sort() // Make the list stable between runs
.map((filePath) => {
const absoluteFilePath = path.resolve(baseDir, filePath);

const isPrerender = filePath.includes("(prerender)");
const isAuth = filePath.includes("(auth)");

const urlRoute = filePath
.split(path.sep)
.slice(0, -1) // Removes the "page.tsx" part
.filter((part) => !ROUTE_GROUP_REGEX.test(part)) // Remove any route groups
.map((part) => part.replace(OPTIONAL_SEGMENT_REGEX, ":$1?")) // Convert optional segments
.map((part) => part.replace(DYNAMIC_SEGMENT_REGEX, ":$1")) // Convert dynamic segments
.join("/");

const routeName = pascalCase(urlRoute) || "Root";

return route(
`${routeName}Route`,
"/" + urlRoute,
page(
ref({
importDefault: `${routeName}Page`,
from: absoluteFilePath,
}),
{ authRequired: isAuth },
),
{ prerender: isPrerender },
);
});
};

That's it. You can start using these conventions as you see fit. For example, with the folder layout below:

src/
├── (prerender)/
│ └── (marketing)/
│ ├── page.tsx
│ └── about-us/
│ └── page.tsx
├── products/
│ └── [productId]
│ └── page.tsx
└── (auth)/
└── dashboard/
└── page.tsx

...our function produces this spec:

export default app({
spec: [
fileBasedRoutes("src"),

// the above call ⬆️ will turn into the following specs ⬇️

route("RootPage", "/", page(RootPage), { prerender: true }),
route("AboutUsPage", "/about-us", page(AboutUsPage), { prerender: true }),
route("ProductIdPage", "/products/:productId", page(ProductIdPage)),
route("DashboardPage", "/dashboard", page(DashboardPage), { authRequired: true }),
],
});

Add more files in that (auth), and those pages become auth-required; name a folder [[slug]] and you get an optional segment. We've covered the most useful bits of Wasp's routing and exposed them through an entirely new API surface, and all of it lives in under 50 lines you can read end to end in a minute.

And the same trick isn't limited to routes. Because the spec is just data your program returns, you can generate any of it the same way: api(), query(), job(), or action(), all from your own file layout. With other frameworks you might need weird hacks or an escape hatch to do this, but in Wasp there's nothing to escape from.

This is the whole reason we keep going on about the spec being a first-class, programmable thing. It means that file-based routing didn't have to become a Wasp feature with a dozen options and an opt-out. It just became a small file you can control. Meta-meta-framework, anyone?

Roll your own

Want a more full-featured file-based router than the version above? We are building one you can use or learn from: wasp-lang/file-based-routing. We're still working on it, but it has quite a few more features than the one here. Other people might create their own libraries, and you can use those too!

But the whole point of this post is that you don't have to use anyone's library. Run wasp new, copy our starting example, and build the conventions that fit your project.

The future

The same "spec is just a program" idea doesn't stop at routing. We're actively working on full-stack modules: the idea that you'll be able to pull libraries that don't only contain backend or frontend code, but full-stack code, defined as a Spec. File-based routing might be a part of those specs in the future! But it all goes towards giving you the magic of conventions without losing control and explicitness when you want it.

If you come up with something good, we'd genuinely love to see it, come share it on our Discord!

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Text AI watermarks will always be trivial to remove

1 Share

The European Union AI Act will begin to be enforceable in August 2026, one month from now1. One of the biggest new requirements is Article 50, which requires all AI outputs to be “detectable as artificially generated”. In other words, if LLM providers want to do business in the EU, they will have to apply a watermark to their outputs2: some hidden signature that can be used to identify AI content.

LLM text watermarking is a fascinating problem. Like the best engineering problems, it is theoretically hard to solve perfectly, but has multiple partial solutions: for instance, Google’s SynthID, and (as I’ll argue) some quiet Unicode trickery from OpenAI and Anthropic. It will be interesting to see how the AI labs navigate these tradeoffs before the end of the year.

Why text watermarking is hard

I wrote about AI watermarking at the end of last year in AI detection tools cannot prove that text is AI-generated. It’s easy to watermark an image, because digital images contain lots of noise that the human eye can’t really see. For instance, you could apply a watermark like “these twenty pixels in these exact spots will always share a color”. Text is much, much harder. Unlike images, text is a very compressed medium: you cannot make any change to a sentence that a human wouldn’t notice (with one exception, which we’ll get to later). So how are you supposed to watermark it?

It’s basically a text steganography problem (concealing a secret code), made more difficult because the plaintext cannot be arbitrarily manipulated. Any changes you make to apply the watermark will compromise the quality of the output. For instance, “every fifth letter is an ‘e’” would be a good watermark, but applied naively would make the AI output full of typos. Could you just let the model figure out how to fit the watermark? Strong AI models are smart enough to juggle this kind of constraint3, but it’d still consume reasoning time that would be better spent on the user’s problem, and make the model sound much less capable than it is4.

Do we need watermarks to detect AI content?

Do you really need a watermark? If you’re Anthropic, and you’re required to be able to verify whether your models produced a particular block of text, can’t you simply run the text through each model, measuring as you go how closely the model’s predicted tokens match each token from the text?

Not really. The space of “all possible Claude Sonnet answers to a question” is way larger than the space of “all possible watermarked answers to a question”. In other words, you’d get too many false positives for human text that reads like it was AI-written. It’s way more likely for a human to accidentally write like Claude than it is for a human to accidentally reproduce a watermark.

It would also be prohibitively expensive to run every Anthropic model against a piece of text in order to watermark it. The EU AI Act will eventually require labs like Anthropic to offer free watermarking services to every EU citizen (see Commitment 2). You couldn’t do that with the “run the model” approach.

How SynthID works

As far as I know, the only AI provider to say they watermark text output is Google, who use a tool called SynthID. Here’s how it works.

When a LLM generates text, it’s generating a series of tokens (words or chunks of words). At each step, the model itself doesn’t output a single token, but instead outputs a full list of all (say) 100,000 tokens in its vocabulary, each annotated with the probability that that token will be the next one. Tools like ChatGPT or Claude Code will pick semi-randomly from the most likely options in order to get their outputs. This semi-random sampling process can be influenced in a detectable way.

For instance, we could choose a sampling strategy like “we pick the second most likely token, then the first, then the second, then the first, and so on”. That would still produce high-quality output, but you’d be able to re-run the model against the generated text to verify that the pattern holds. However, that’d make verification really expensive, and any slight tweaks to the output would break the pattern and thus break the fingerprint. Is there a better way?

Yes. SynthID is a process for assigning each token a “score” based on its previous tokens (for instance, sum the token’s ID with the IDs of its previous three tokens then take mod 5)5. To apply the watermark, the model adopts a sampling strategy like “out of the top five most likely tokens, pick the one with the top SynthID score”6. The watermark can then be detected by calculating the aggregate SynthID score of a block of text. If it’s suspiciously high, it’s very likely to have been AI-generated.

This is basically a version of the common advice that you can identify LLMs by use of the em-dash, except that instead of a list of keywords, it relies on subtle mathematical relationships between words that humans can’t identify. Because the process for assigning the score is trivial, it’s very cheap to run watermark detection.

Unicode watermarks via homoglyphs

Google have a complicated mathematical rationale for why SynthID doesn’t make the model dumber: supposedly the SynthID scoring is random enough to act like a normal pseudo-random token sampler, just one that leaves a detectable fingerprint on the outputs. But of course this is suspicious. For instance, it’s common to do inference setting temperature to zero, which always picks the model’s most likely next token. In that case, you can’t leave a fingerprint at all (or you have to ignore the user’s preference and pick the second or third choice anyway).

If you can’t alter the model outputs, can you still fingerprint the content? Well, kind of. I’m pretty sure OpenAI and Anthropic are sometimes applying fancy Unicode tricks. For instance, you might go through and replace your normal ” ” spaces (unicode U+0020) with a three-per-em ” ” space (unicode U+2004), or a CJK ideographic ” ” space (unicode U+3000). These are called “homoglyphs”, and you can find more of them here.

Of course, lots of human-generated text uses homoglyphs. But it’s trivial to encode a pattern of homoglyphs (say, “every third space becomes a three-per-em”) that is much less likely to occur in the wild. Like the SynthID watermark, a homoglyph-based watermark can be detected very cheaply. A homoglyph-based watermark is cheaper to apply than SynthID: you could even do it entirely on the client.

I don’t think this is a conspiracy theory. Claude Code was definitely doing this to tag suspicious requests from Chinese users (exploiting homoglyphs for the ’ character in “Today’s date”, though they’ve since walked that back). In the last few years, I’ve noticed that when I copy blocks of text from ChatGPT and paste them into VSCode, sometimes VSCode marks some or all of the spaces as unusual Unicode characters7. Are OpenAI and Anthropic using homoglyphs as an AI-generated watermark? I’m not sure. But they’re definitely using homoglyphs.

Text watermarks can be trivially removed

The AI Act (specifically, its associated Code of Practice) requires watermarking to be “embedded within the content in a manner that is difficult for it to be separated from the content”. However, text watermarks can be trivially removed.

To remove unicode homoglyph watermarking, you simply have to replace all the homoglyphs with their “real” character equivalents. If you have access to even a relatively weak un-watermarked LLM8, you can strip out SynthID watermarking by asking that LLM to paraphrase the text content. Because the watermark is inherent to subtle vocabulary choices, re-wording the content will remove the watermark. You could even do it by hand, although at that point it’s not really AI-generated content anymore. Since there will be some kind of free public watermark testing tool, you can just keep tweaking until it comes back negative.

Moreover, the AI Act requires watermarking techniques to be “interoperable… as far as this is technically feasible”. That means AI providers would have to publish their watermarking process, and potentially even attempt to standardize on applying the same kind of watermarks. I just don’t see how this is compatible with the kind of security-by-obscurity that LLM text watermarking depends on. Unlike image and video watermarks, text watermarks will always be trivial to remove.

What about C2PA?

The AI Act and Code of Practice talk a lot about “digitally signed metadata”. The idea here is that you can include an AI disclosure in the file’s metadata itself, ideally in a way that cannot be tampered with (for instance, by signing a hash of the file’s contents). This signed-metadata process is basically C2PA Content Credentials. While you can remove C2PA metadata, you (theoretically) can’t fake it, so a file with “created by a human” metadata can be trusted, and files with no metadata at all can be held in suspicion.

This post is already too long to get into what I think about C2PA, but I do want to say that C2PA is not a substitute for text watermarking. It only really applies to files. In the words of the Code of Practice, that’s “a data format that supports attaching metadata (e.g., an audio, image, video, or containerised text)“. The output of chat tools (and most of the output of AI agents) is not containerized text, but plain old regular text, and so can’t be signed. What would it even look like to sign ChatGPT outputs? There’s no artifact to pass around.

I think it’s a fascinating question whether Claude Code has to C2PA-sign any HTML files or PDFs it generates for you. That seems kind of tricky to get right. But in any case, the AI Act also mandates some kind of actual watermarking as well.

Conclusion

So what’s going to happen this year? If I had to guess, I’d say that each AI provider (not just labs like OpenAI or Anthropic, but third-party providers like Fireworks or Groq) will stick a SynthID token sampler in front of their inference stacks. This might be limited to users in the EU, but it might not be, since SynthID is at least as good as a normal top-k token sampling approach.

AI providers will then offer a “check for watermark” page that re-tokenizes user-provided text, runs the scoring, and checks whether it’s above a certain threshold. Depending on how seriously the interoperability clause is taken, providers might even standardize on the same SynthID setup, in which case there could be a single EU-hosted “watermark this text” page.

I don’t think unicode-based watermarking is going to be considered compliant with the AI Act, but some providers which don’t want to set up SynthID might try it. Either way, technical users will be able to strip out the watermark at will, and there will be a plethora of tools that non-technical users will use for this purpose.


  1. Well, for new systems; existing ones get until December.

  2. I don’t think the plain text of Article 50 requires this, but Recital 133 and the Code of Practice makes it pretty clear that they’re looking for watermarks.

  3. Even with extra high thinking, GPT-5.5 could not explain SynthID to me with every fifth letter being an “e”, but GPT-5.5-Pro produced this puzzling koan: “These hidden codes label model-made image, voice, movie, prose. Probe trace: maybe a model-made piece. Maybe erase trace; maybe leave trace. Hence trace alone? No.”

  4. I leave the analogy with AI safety guardrails as an exercise for the reader.

  5. That’s a toy example. In practice there are multiple different (but still mathematically simple) scoring methods that get combined together, including a random seed. Why include the seed? Otherwise the watermark would bias towards the same set of tokens.

  6. The tokens are scored in a multi-round knockout against each other, but I think that’s more of an implementation detail and not required to get the core intuition behind why SynthID works.

  7. When this became public knowledge, OpenAI claimed it was just a model quirk, which is certainly possible.

  8. All AI providers might be legally required to watermark, but even tiny local models are good enough to paraphrase text.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Audit Frontier AI Agents with SQL MCP Server

1 Share

All modern AI solutions eventually need agents to query or mutate data in Microsoft SQL. Deciding how to do this safely, for example, how to handle NL2SQL, will be an important choice. Next come the twins: authentication and authorization. In this article, we discuss pass-through agentic authentication with the express goal of ensuring operational logs reflect not the agent, not the MCP server, but the user. In future articles, we will discuss the broad array of authorization controls available in Data API builder (DAB) 2.0 with SQL MCP Server.

There are three approaches to SQL authentication in SQL MCP Server.

The first is a username and password. Although it is slowly waning in popularity, it remains a viable option for certain customers. In this case, the audit logs show the identity provided through the connection string, not the user invoking the operation.

User -(any authentication approach)-> Agent
Agent -(any authentication approach)-> MCP server
MCP server -(passes username and password)-> SQL db
SQL db -(logs username as operator)-> Logs

The second is managed identity (MI), which eliminates the password but produces the same logging outcome: the logs identify the application, not the user invoking the data operation.

User -(any authentication approach)-> Agent
Agent -(any authentication approach)-> MCP server
MCP server -(passes MCP server identity)-> SQL db
SQL db -(logs MCP server identity as operator)-> Logs

The third approach is On-Behalf-Of (OBO), also known as pass-through authentication, using Microsoft Entra ID. It is more sophisticated to set up, but when enabled, services exchange the incoming user token for downstream tokens, forwarding them from service to service.

User -(authenticates with Microsoft Entra ID)-> Agent
Agent -(forwards user token)-> MCP server
MCP server -(exchanges token and connects as user)-> SQL db
SQL db -(logs user as operator)-> Logs

Ultimately, Azure SQL authenticates the actual calling user, not the application, not the agent, and not the MCP server. When a query or mutation occurs in the database, the resulting audit logs identify the user who invoked the operation.

Data API builder (DAB) 2.0 with SQL MCP Server OBO support

Data API builder (DAB) 2.0 with SQL MCP Server supports On-Behalf-Of (OBO) authentication for Microsoft SQL databases using Microsoft Entra ID. When enabled, it changes your audit story for agentic apps. Instead of asking “which service queried the database?” SQL can answer “which user caused this query?” Let’s take a look.

Agents need accountability

AI agents can reason, plan, and call tools. But when an agent reaches into production data, the enterprise question is not only “did the agent have permission?” The better question is “whose permission did the agent use?”

SQL MCP Server exposes SQL tables, views, and stored procedures through MCP tools. This gives agents a controlled way to interact with data without exposing the database directly. With OBO, that controlled path can also preserve the signed-in user’s identity all the way to Azure SQL.

Configuring OBO

OBO is configured in the DAB data source. The connection string must be a bare Azure SQL connection string. Don’t include User ID, Password, or Authentication. DAB injects the per-user access token when it opens the SQL connection.

{
  "data-source": {
    "database-type": "mssql",
    "connection-string": "@env('MSSQL_CONNECTION_STRING')",
    "user-delegated-auth": {
      "enabled": true,
      "provider": "EntraId",
      "database-audience": "https://database.windows.net"
    }
  }
}

Because each user receives a distinct database connection, caching might be an initial concern. However, DAB has a hard configuration validation rule that makes response caching and OBO/user-delegated authentication mutually exclusive. The validator rejects any configuration that enables both simultaneously. The OBO token cache itself is scoped per user.

Validating the identity

The online documentation OBO sample uses a small WhoAmI view to prove the point. This view returns the identity SQL sees on the active connection.

CREATE VIEW [dbo].[WhoAmI] AS
SELECT SUSER_NAME() AS [UserName];

Expose it through DAB for authenticated users.

{
  "WhoAmI": {
    "source": {
      "object": "dbo.WhoAmI",
      "type": "view",
      "key-fields": [ "UserName" ]
    },
    "rest": {
      "enabled": true
    },
    "permissions": [
      {
        "role": "authenticated",
        "actions": [ { "action": "read" } ]
      }
    ]
  }
}

With this, an authenticated request to WhoAmI returns the user principal name SQL sees. In the app, the user signs in to the browser with Microsoft Entra ID, sends a bearer token to Data API builder (DAB) 2.0 with SQL MCP Server, and DAB exchanges that token for an Azure SQL token for the signed-in user.

const headers = await getAuthHeaders();

const response = await fetch(`${API_URL}/api/WhoAmI`, {
    method: "GET",
    headers
});

const payload = await response.json();
const sqlUserName = payload.value[0].UserName;

console.log(`SQL sees this request as: ${sqlUserName}`);

The sample’s UI displays the result as “SQL Server sees you as: [user@example.com](mailto:user@example.com).” The README describes the point plainly: SQL sees the real user, not a service account.

What this means for MCP

One configuration for all the endpoints

The same DAB runtime can expose REST, GraphQL, and MCP.

In the OBO sample, MCP is enabled with the /mcp path in the same configuration that enables Entra ID authentication and user-delegated auth.

A conceptual MCP tool request can read the same WhoAmI entity.

{
    "tool": "read_records",
    "arguments": {
        "entity": "WhoAmI"
    }
}

The agent is still using a tool. DAB is still enforcing its entity permissions. Azure SQL still authenticates the user behind the request.

That is the key distinction.

The agent performs the action, but SQL records the user context that authorized the action.

Auditing the result

Azure SQL auditing tracks database events and can write them to Blob storage, Event Hubs, or Log Analytics. The audit schema includes fields such as database_principal_name, server_principal_name, statement, and obo_middle_tier_app_id, which identifies the middle-tier app that connected using OBO access.

In Log Analytics, a simple query can show who SQL saw.

AzureDiagnostics
  | where Category == "SQLSecurityAuditEvents"
  | where database_name_s == ""
  | project
  event_time_t,
  action_name_s,
  database_principal_name_s,
  server_principal_name_s,
  obo_middle_tier_app_id_s,
  statement_s
  | order by event_time_t desc

That gives you a better audit frontier for agentic systems. You can see the user, the SQL action, the statement, and the middle-tier app involved in the OBO path.

Conclusion

OBO pass-through authentication makes Data API builder (DAB) 2.0 with SQL MCP Server more than a convenient bridge between agents and data. It makes the bridge accountable. For simple apps, connecting with a managed identity or service credential can be enough. For enterprise agents touching sensitive data, the database often needs to know the real caller. With SQL MCP Server and DAB OBO authentication, Azure SQL can audit the signed-in user behind the agent action. That means your agent can call tools, DAB can enforce permissions, and SQL can still answer the most important audit question: who did this?

The post Audit Frontier AI Agents with SQL MCP Server appeared first on Azure SQL Dev Corner.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories