Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ Issues and pull requests are welcome. Before opening one:
Screenshots go to `/tmp/t3-auto-update-ui-audit` (`T3_AUTO_UPDATE_UI_OUTPUT`
overrides it).

For shared MCP changes, run `node scripts/mcp-native-smoke.mjs PROVIDER
/absolute/path/to/executable` for Claude, Codex, OpenCode (both V1 and V2) and
Cursor. It uses disposable homes and fixture servers without model requests.
`node scripts/mcp-ui-audit.js` checks the MCP page’s matrix, per-agent Off/On,
snippet import, masked values, edit/update, sharing consent, partial removal,
revision conflicts and failed/late reads in both themes at phone, desktop
and embedded sizes. It uses response fixtures, with screenshots under
`/tmp/t3-mcp-ui-audit`. The standard screenshot matrix includes `mcp`.

For OpenCode credential changes, run
`node scripts/opencode-auth-smoke.mjs /absolute/path/to/opencode` against both
1.x and 2.x (including 2.0.18, the supported V2 floor). It uses an isolated
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ instead. Details: [Reaching the server from a phone](docs/remote-access.md).
| [Source control](docs/source-control.md) | gh, glab, fj, tea and az sign-in |
| [Publishing a port](docs/ports.md) | Public URLs for dev servers |
| [Giving agents a browser](docs/agent-browser.md) | T3's server browser and the browser MCP servers |
| [Context management](docs/context-management.md) | Global `AGENTS.md` and skills |
| [Context management](docs/context-management.md) | Global `AGENTS.md` and the shared skill library |
| [MCP servers](docs/mcp-servers.md) | Define once, compare and configure native agent copies |
| [Storage management](docs/storage.md) | Disk usage, cache cleanup, version retention |
| [Configuration](docs/configuration.md) | Every environment variable, volumes, building |
| [Troubleshooting](docs/troubleshooting.md) | Pairing, providers, terminals, permissions |
Expand Down
67 changes: 19 additions & 48 deletions docker/bin/t3-browser-mcp
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ Notes:
* Chromium is sandboxed by the container already, so --no-sandbox is passed.
* chrome-devtools-mcp officially supports Google Chrome rather than Debian's
Chromium; it does work here, but playwright is the tested default.
* Each harness is registered through its managed executable. Install the
harness (setup page or `t3-harness install`) before registering MCP with it.
* Install the managed harness (setup page or `t3-harness install`) first.
Claude uses its native CLI; Codex/OpenCode use the setup config adapters.
USAGE
}

Expand Down Expand Up @@ -105,8 +105,6 @@ harness_exec() { # id
fi
}

json_args="$(printf '%s\n' "${args[@]}" | jq -R . | jq -s .)"

register_claude() {
local bin
bin="$(harness_exec claude)"
Expand All @@ -124,60 +122,33 @@ register_claude() {
fi
}

# Use the console's adapters so both entry points agree on JSONC precedence,
# OpenCode V2 nesting, TOML sections, backups and file symlinks.
write_browser_entry() {
"$T3_INFRA_NODE" --input-type=module - "$1" "$name" "$action" "$cmd" "${args[@]}" <<'NODE'
import * as io from "node:fs/promises";
const { writeProviderMcpEntry } = await import(process.env.T3_MCP_ADAPTERS_MODULE || "file:///opt/t3-setup/mcp/adapters.mjs");
const [provider, name, action, command, ...args] = process.argv.slice(2);
const entry = action === "remove" ? null : provider === "codex"
? { command, args } : { type: "local", command: [command, ...args], enabled: true };
await writeProviderMcpEntry(io, provider, { home: process.env.HOME, env: process.env }, name, entry);
NODE
}

register_codex() {
local bin
bin="$(harness_exec codex)"
[ -n "$bin" ] || { echo " codex: not installed"; return; }
local config="${CODEX_HOME:-$HOME/.codex}/config.toml"
mkdir -p "$(dirname "$config")"
touch "$config"

# Drop any block we previously wrote, then re-add if needed.
local tmp; tmp="$(mktemp)"
awk -v marker="# >>> t3-browser-mcp:${name} >>>" \
-v endmarker="# <<< t3-browser-mcp:${name} <<<" '
$0 == marker { skip = 1 }
skip != 1 { print }
$0 == endmarker { skip = 0 }
' "$config" > "$tmp"
mv "$tmp" "$config"

if [ "$action" = "remove" ]; then
echo " codex: removed $name"
return
fi

{
printf '\n# >>> t3-browser-mcp:%s >>>\n' "$name"
printf '[mcp_servers.%s]\n' "$name"
printf 'command = "%s"\n' "$cmd"
printf 'args = %s\n' "$(printf '%s' "$json_args" | jq -c .)"
printf '# <<< t3-browser-mcp:%s <<<\n' "$name"
} >> "$config"
echo " codex: registered $name in $config (via $bin)"
write_browser_entry codex
echo " codex: $action $name (via $bin)"
}

register_opencode() {
local bin
bin="$(harness_exec opencode)"
[ -n "$bin" ] || { echo " opencode: not installed"; return; }
local config="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json"
mkdir -p "$(dirname "$config")"
[ -s "$config" ] || echo '{"$schema":"https://opencode.ai/config.json"}' > "$config"

local tmp; tmp="$(mktemp)"
if [ "$action" = "remove" ]; then
jq --arg n "$name" 'del(.mcp[$n])' "$config" > "$tmp"
mv "$tmp" "$config"
echo " opencode: removed $name"
return
fi

jq --arg n "$name" --arg c "$cmd" --argjson a "$json_args" \
'.mcp[$n] = {type: "local", command: ([$c] + $a), enabled: true}' \
"$config" > "$tmp"
mv "$tmp" "$config"
echo " opencode: registered $name in $config (via $bin)"
write_browser_entry opencode
echo " opencode: $action $name (via $bin)"
}

printf '%s %s (%s)\n' \
Expand Down
11 changes: 7 additions & 4 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,9 @@ register_projects
# and az keep their sign-ins in these.
AGENT_DIRS=".claude .codex .cursor .grok .config/opencode .local/share/opencode .config/muse .local/share/muse .agents/skills .local/state/skills .gemini/config/skills .gemini/antigravity-cli/skills"
AGENT_DIRS="$AGENT_DIRS .config/gh .config/glab-cli .config/tea .local/share/forgejo-cli .azure"
# And git's own settings: who you commit as, and the credential helpers gh and
# tea set up so an HTTPS push uses their sign-in. git writes through the link.
AGENT_FILES=".gitconfig .agents/.skill-lock.json"
# And git's own settings, plus Claude's global account/MCP config outside its
# config directory. Git and Claude's native writer preserve these file links.
AGENT_FILES=".gitconfig .agents/.skill-lock.json .claude.json"

persist_agent_credentials() {
[ "$T3_PERSIST_AGENT_CREDENTIALS" = "1" ] || return 0
Expand Down Expand Up @@ -299,7 +299,10 @@ persist_agent_credentials() {
# The skills CLI owns its JSON lock format. An absent lock stays absent;
# its future write follows the link onto the volume without an empty or
# invented JSON document. This does not move the rest of .agents.
if [ "$rel" != ".agents/.skill-lock.json" ]; then
if [ "$rel" = ".claude.json" ]; then
[ -e "$dst" ] || printf '{}\n' > "$dst"
chmod 600 "$dst"
elif [ "$rel" != ".agents/.skill-lock.json" ]; then
[ -e "$dst" ] || : > "$dst"
fi
;;
Expand Down
23 changes: 21 additions & 2 deletions docker/setup/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
const storageUI = T3StorageUI.create({ api, getStatus: () => state.status, reload: () => loadStatus(), render: () => render() });
const skillsUI = T3SkillsUI.create({ api, getStatus: () => state.status, render: () => render(),
readMarkdown: (file) => readMarkdownFile(file, 64 * 1024), downloadMarkdown: (...args) => downloadMarkdown(...args) });
const mcpUI = T3McpUI.create({ api, getStatus: () => state.status, render: () => render() });

/** Whether this browser came in on its T3 Code session rather than the key, so Lock would end nothing. */
const viaT3 = (s) => Boolean(s && s.viewer && s.viewer.via === 't3');
Expand Down Expand Up @@ -212,6 +213,7 @@
tellAttention();
checkHere();
if (state.route === 'instructions' && state.contextTab === 'skills') skillsUI.activate();
if (state.route === 'mcp') mcpUI.activate();
if (state.route === 'instructions' && state.contextTab === 'instructions') void loadInstructionsPreview();
} else if (!locked) {
// Name the URL and the reason. "Could not read status" sent someone
Expand Down Expand Up @@ -1784,6 +1786,7 @@
// ---------------------------------------------------------------- shell --
const PAGES = {
overview: overviewPage, access: accessPage, agents: agentsPage, instructions: instructionsPage,
mcp: () => html`${mcpUI.phoneHead()}${pageHead('mcp', { sub: 'Servers your agents can start or connect to. Define one once, then add it to the agents you choose.' })}${mcpUI.content()}`,
toolchains: toolchainsPage, sourcecontrol: sourceControlPage, ports: portsPage,
updates: updatesPage, storage: storagePage, server: serverPage, more: morePage,
};
Expand Down Expand Up @@ -1848,6 +1851,7 @@
// Access), which is rewritten in place so the address bar says where it is.
const routeFromHash = () => {
const [name, sub] = location.hash.replace(/^#\/?/, '').split('?')[0].split('/');
if (name === 'instructions' && sub === 'mcp') { history.replaceState(null, '', '#mcp'); return 'mcp'; }
const route = T3Routes.resolve(name);
if (route && route !== name) history.replaceState(null, '', '#' + route + (sub ? '/' + sub : ''));
if (route === 'instructions') state.contextTab = sub === 'skills' ? 'skills' : 'instructions';
Expand Down Expand Up @@ -1880,6 +1884,7 @@
if (route === 'ports' && !state.ports) loadPorts();
if (route === 'instructions' && state.contextTab === 'skills' && state.status) skillsUI.activate(true);
if (route === 'instructions' && state.contextTab === 'instructions' && state.status) void loadInstructionsPreview(true);
if (route === 'mcp' && state.status) mcpUI.activate(true);
};
const navigateFromHash = async focus => {
const version = ++navigationVersion;
Expand All @@ -1888,7 +1893,8 @@
// keeps the old route, tab and draft together.
if (!navigationTask) navigationTask = (async () => {
if (contextEditor.layer && !await contextEditor.requestClose()) return false;
return skillsUI.beforeNavigate();
if (!await skillsUI.beforeNavigate()) return false;
return mcpUI.beforeNavigate();
})();
const allowed = await navigationTask;
if (version !== navigationVersion) return;
Expand Down Expand Up @@ -3509,6 +3515,18 @@
'skills.group': (a) => skillsUI.group(a.id),
'skills.copy': (a) => skillsUI.copy(a.id, a.provider),
'skills.copy-group': (a) => skillsUI.copyGroup(a.id),
'mcp.refresh': () => mcpUI.refresh(),
'mcp.view': (a) => mcpUI.view(a.id, a.provider),
'mcp.add': () => mcpUI.add(),
'mcp.cell': (a, el) => mcpUI.cellMenu(a.id, a.provider, el),
'mcp.agent': (a, el) => mcpUI.agentMenu(el),
'mcp.filter': (a) => mcpUI.filterAgent(a.provider || ''),
'mcp.attention': () => mcpUI.attention(),
'mcp.columns': (a, el) => mcpUI.columnsMenu(el),
'mcp.group': (a) => mcpUI.group(a.id),
'mcp.share': (a) => mcpUI.share(a.id, a.provider),
'mcp.link': (a) => mcpUI.toggle(a.provider, a.id),
'mcp.apply': (a) => mcpUI.apply(a.provider, a.id),
'storage.goto': () => go('storage'),
refresh: () => { loadStatus(); loadPorts(); },
goto: (a) => go(a.route),
Expand Down Expand Up @@ -3868,6 +3886,7 @@
render();
});
document.addEventListener('change', (e) => {
if (e.target.id === 'mcp-provider') mcpUI.select(e.target.value);
if (e.target.hasAttribute('data-skill-policy')) skillsUI.setState(e.target.getAttribute('data-skill-policy'), e.target.getAttribute('data-skill-provider'), e.target.value);
});

Expand Down Expand Up @@ -3909,7 +3928,7 @@

// ------------------------------------------------------------ shortcuts --
Kit.bind('mod+k', openPalette);
Kit.bind('/', () => state.route === 'instructions' && state.contextTab === 'skills' ? skillsUI.focusSearch() : openPalette());
Kit.bind('/', () => state.route === 'mcp' ? mcpUI.focusSearch() : state.route === 'instructions' && state.contextTab === 'skills' ? skillsUI.focusSearch() : openPalette());
for (const page of T3Routes.PAGES) {
if (page.key) Kit.bind('g ' + page.key, () => { keyboardNav = true; go(page.route); });
}
Expand Down
13 changes: 10 additions & 3 deletions docker/setup/client/kit.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ const Kit = (() => {
};
PHONE.addEventListener('change', syncDensity);

// Native Tab focus can leave a partially visible filter clipped. Reveal the
// whole chip using its scrollport's focus-ring padding, without centering it.
document.addEventListener('focusin', (event) => {
const chip = event.target.closest?.('.tc-sk-agent-chips .tc-chip-btn');
if (chip) chip.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
});

const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusables = (root) => [...root.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
const isTyping = (el) => Boolean(el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.nodeName)) && !(el.type === 'checkbox' || el.type === 'radio' || el.type === 'button'));
Expand Down Expand Up @@ -331,7 +338,7 @@ const Kit = (() => {
* items [{label, icon, danger, run, radio?, checked?, description?} | {sep: true}]
* footer escaped Kit markup for settings paths or other contextual help
*/
const menu = (trigger, { label, head, items, footer }) => {
const menu = (trigger, { label, head, items, footer, status }) => {
const current = top();
if (current && current.kind === 'menu' && current.returnTo === trigger) { current.close('toggle'); return null; }
if (isPhone()) {
Expand All @@ -342,7 +349,7 @@ const Kit = (() => {
returnTo: trigger,
render: () => html`
<div class="tc-sheet-head tc-menu-head">${head && head.tile ? head.tile : ''}<div class="tc-menu-head-text"><h2 class="tc-sheet-title" id="${titleId}">${(head && head.title) || label}</h2>${head && head.sub ? html`<span class="tc-small tc-muted">${head.sub}</span>` : ''}</div></div>
<div class="tc-sheet-actions">${menuItems(items)}${footer ? html`<div class="tc-menu-foot">${footer}</div>` : ''}</div>
<div class="tc-sheet-actions">${status || ''}${menuItems(items)}${footer ? html`<div class="tc-menu-foot">${footer}</div>` : ''}</div>
<div class="tc-sheet-cancel"><button class="tc-btn tc-btn--block tc-btn--lg" type="button" data-menu-cancel>Cancel</button></div>`,
backdrop: 'close',
});
Expand All @@ -359,7 +366,7 @@ const Kit = (() => {
backdrop: false,
returnTo: trigger,
panel: { class: 'tc-menu', role: 'menu', 'aria-label': label },
render: () => html`${head && head.title ? html`<div class="tc-menu-label">${head.title}${head.sub ? ' · ' + head.sub : ''}</div>` : ''}${menuItems(items)}${footer ? html`<div class="tc-menu-foot">${footer}</div>` : ''}`,
render: () => html`${head && head.title ? html`<div class="tc-menu-label">${head.title}${head.sub ? ' · ' + head.sub : ''}</div>` : ''}${status || ''}${menuItems(items)}${footer ? html`<div class="tc-menu-foot">${footer}</div>` : ''}`,
onClose: () => trigger.setAttribute('aria-expanded', 'false'),
});
// 4px under the trigger, right edges aligned; above it if there is no room.
Expand Down
32 changes: 32 additions & 0 deletions docker/setup/client/mcp-command.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Shared by the browser editor and the pure snippet parser. This is a lexer,
// never a shell: variables, globs, substitutions and metacharacters stay literal.
var T3McpCommand = (() => {
'use strict';
const lex = text => {
const words = []; let word = '', quote = '', started = false;
for (let at = 0; at < text.length; at++) {
const c = text[at];
if (quote === "'") { if (c === quote) quote = ''; else word += c; continue; }
if (c === '\\') {
if (++at >= text.length) throw new Error('Finish the escaped character at the end of the command.');
const next = text[at];
if (quote === '"' && !['$', '`', '"', '\\', '\n'].includes(next)) word += '\\';
if (next !== '\n') word += next;
started = true; continue;
}
if (quote) { if (c === quote) quote = ''; else word += c; continue; }
if (c === '"' || c === "'") { quote = c; started = true; }
else if (/\s/.test(c)) { if (started) { words.push(word); word = ''; started = false; } }
else { word += c; started = true; }
}
if (quote) throw new Error('Close the quote in the command.');
if (started) words.push(word);
return words;
};
const quote = arg => /^[A-Za-z0-9_./:@=+-]+$/.test(arg) ? arg : "'" + arg.replace(/'/g, "'\\''") + "'";
const line = argv => argv.map(quote).join(' ');
const needsRows = argv => argv.some(arg => !arg || arg !== arg.trim() || /[\r\n]/.test(arg));
const placeholder = value => /<[^>]+>|YOUR_|your-/i.test(value) || /^(xxx|\.\.\.|changeme|replace-me|todo)$/i.test(value);
return { lex, quote, line, needsRows, placeholder };
})();
if (typeof module === 'object') module.exports = T3McpCommand;
Loading
Loading