Skip to content
Open
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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ Issues and pull requests are welcome. Before opening one:
set `T3_STORAGE_UI_OUTPUT` to change that location. The development console
isolates its home, mise state, and caches under `/tmp/t3-dev/home`.

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 tab's apply/unlink controls,
revisions, JSON values 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 `instructions/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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ 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`, skills and shared MCP servers |
| [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
24 changes: 18 additions & 6 deletions docker/setup/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,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 @@ -207,6 +208,7 @@
tellAttention();
checkHere();
if (state.route === 'instructions' && state.contextTab === 'skills') skillsUI.activate();
if (state.route === 'instructions' && state.contextTab === '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 @@ -1603,14 +1605,16 @@
</div>`;
};

const CONTEXT_TABS = [['instructions', 'Instructions'], ['skills', 'Skills']];
/** Instructions and skills: two tabs on one page, each at an address of its own. */
const CONTEXT_TABS = [['instructions', 'Instructions'], ['skills', 'Skills'], ['mcp', 'MCP servers']];
/** Instructions, skills and MCP servers: three tabs on one page, each at an address of its own. */
const instructionsPage = (s) => {
const tab = state.contextTab;
const rows = M.contextProviderRows(s);
const view = M.contextView(s);
const panel = tab === 'skills'
? skillsUI.content()
: tab === 'mcp'
? mcpUI.content()
: html`
${view.error ? notice('danger', 'circle-alert', 'Couldn’t read the instructions', view.error) : ''}
<div class="tc-ins-grid"><section class="tc-ins-section"><div class="tc-ins-section-head"><h2 class="tc-insp-label">Shared instructions</h2><span class="tc-hint">${M.contextAgentsMdRow(s).readers.length ? 'Read by ' + M.listOf(M.contextAgentsMdRow(s).readers) : 'No agents set to Shared'}</span></div><div class="tc-group">
Expand All @@ -1625,7 +1629,7 @@
return html`
<header class="tc-instructions-phone-head"><a class="tc-btn tc-btn--ghost" href="#more">${icon('chevron-right', 'tc-back-icon')}Setup</a><h1>Instructions & skills</h1>${EMBED ? html`<button class="tc-btn tc-btn--ghost tc-btn--icon" type="button" data-cmd="embed.close" aria-label="Close setup">${icon('x')}</button>` : html`<a class="tc-btn tc-btn--ghost tc-btn--icon" href="${T3Routes.docUrl('context-management.md', s.image?.version)}" target="_blank" rel="noopener" aria-label="Instructions and skills guide">${icon('info')}</a>`}</header>
${pageHead('instructions')}
<div class="tc-tabs" role="tablist" aria-label="Instructions and skills" data-key="cx-tabs">${CONTEXT_TABS.map(([value, label]) => html`<button class="tc-tab-button" type="button" role="tab" id="cx-tab-${value}" aria-controls="cx-panel" aria-selected="${String(tab === value)}" tabindex="${tab === value ? '0' : '-1'}" data-cmd="context.tab" data-value="${value}">${label}${value === 'skills' && skillsUI.count() ? html` <span class="tc-tab-count">${skillsUI.count()}</span>` : ''}</button>`)}</div>
<div class="tc-tabs" role="tablist" aria-label="Instructions, skills and MCP servers" data-key="cx-tabs">${CONTEXT_TABS.map(([value, label]) => html`<button class="tc-tab-button" type="button" role="tab" id="cx-tab-${value}" aria-controls="cx-panel" aria-selected="${String(tab === value)}" tabindex="${tab === value ? '0' : '-1'}" data-cmd="context.tab" data-value="${value}">${label}${value === 'skills' && skillsUI.count() ? html` <span class="tc-tab-count">${skillsUI.count()}</span>` : ''}</button>`)}</div>
${pageNotices('instructions')}
<div class="tc-tabpanel" role="tabpanel" id="cx-panel" aria-labelledby="cx-tab-${tab}" data-key="cx-panel-${tab}">${panel}</div>`;
};
Expand Down Expand Up @@ -1837,7 +1841,7 @@
const [name, sub] = location.hash.replace(/^#\/?/, '').split('?')[0].split('/');
const route = T3Routes.resolve(name);
if (route && route !== name) history.replaceState(null, '', '#' + route + (sub ? '/' + sub : ''));
if (route === 'instructions') state.contextTab = sub === 'skills' ? 'skills' : 'instructions';
if (route === 'instructions') state.contextTab = sub === 'skills' ? 'skills' : sub === 'mcp' ? 'mcp' : 'instructions';
return route && PAGES[route] ? route : 'overview';
};
const go = (route) => {
Expand Down Expand Up @@ -1867,6 +1871,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 === 'instructions' && state.contextTab === 'mcp' && state.status) mcpUI.activate();
};
const navigateFromHash = async focus => {
const version = ++navigationVersion;
Expand All @@ -1875,7 +1880,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 @@ -3470,6 +3476,11 @@
'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),
'mcp.add': () => mcpUI.add(),
'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 @@ -3693,7 +3704,7 @@
'link.revoke': (a) => confirmRevoke('pairing', a.id),

'context.tab': (a) => {
const hash = '#instructions' + (a.value === 'skills' ? '/skills' : '');
const hash = '#instructions' + (a.value === 'skills' ? '/skills' : a.value === 'mcp' ? '/mcp' : '');
if (location.hash !== hash) location.hash = hash;
},
'context.preview.retry': () => loadInstructionsPreview(true),
Expand Down Expand Up @@ -3824,6 +3835,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
Loading
Loading