Back to all posts

Build, Test & Debug GitHub Copilot MCP Apps

Learn to build, test, and debug GitHub Copilot MCP Apps locally with MCPJam. Render UIs, trace JSON-RPC messages, test tools, and verify in VS Code.

Aditya Somani18 min read
In this guide7 sections

Building an MCP App for GitHub Copilot can be difficult when you can't see what's happening between your UI, your MCP server, and Copilot. Copilot Chat renders the app's UI inline, under the tool call that returned it, but testing it there is slow. Every change costs a chat turn, the model may pick different arguments each time, and the chat shows the tool call but not the JSON-RPC messages behind it: the handshake when your UI connects, the tool result Copilot delivers to it, and the calls your UI makes back to your server.

With MCPJam, you can render the UI in an emulated Copilot client, replay tool calls with fixed inputs, and trace those messages on your machine before you open Copilot at all:

  • Part 1: Run the task board from the Claude tutorial, which works in Copilot unchanged, and adjust its descriptions so Copilot doesn't route task requests to its built-in #todos tool.
  • Part 2: Test and debug it with MCPJam's Copilot client: rendering, the app bridge, CSP, message tracing, fixed-input tool calls, model testing, and a final check in GitHub Copilot.

This guide is part of a series on building MCP Apps for different clients. If you're also targeting Claude or ChatGPT, the Claude tutorial and the ChatGPT tutorial cover the same task board so you can compare the approaches.

You can find the complete code on GitHub.

How a GitHub Copilot MCP App fits together

GitHub Copilot supports the open MCP Apps standard, so a tool can return an interactive UI that renders inline in chat instead of as plain text. Copilot calls MCP tools in agent mode, the mode where it can use tools on its own. The Copilot client renders apps inline by default and allows fullscreen on desktop.

A tool returns data, a ui:// resource serves the HTML, and _meta.ui.resourceUri links the two. Copilot renders the HTML in a sandboxed iframe, and the UI talks to Copilot over a JSON-RPC bridge. For the check in a real client (step 9), we'll use the GitHub Copilot Chat extension in VS Code, which connects to a local server directly.

The MCP Apps SDK wraps each protocol message in a helper, so you don't have to write the messages by hand. Copilot supports most of them, with a few differences in behavior. The last column shows the names ChatGPT accepts if you're porting a ChatGPT App:

Swipe to see all columns →

MCP Apps standardGoalSDK helperCopilot behaviorChatGPT alias
_meta.ui.resourceUriLink a tool to a UI resourceregisterAppTool()Supported_meta["openai/outputTemplate"]
ui/initialize + ui/notifications/tool-inputReceive tool inputapp.ontoolinputSupported, including partial input while the model streams itwindow.openai.toolInput
ui/notifications/tool-resultReceive tool resultsapp.ontoolresultSupportedwindow.openai.toolOutput
tools/callCall a tool from the UIapp.callServerTool()Supportedwindow.openai.callTool
ui/messageSend a follow-up messageapp.sendMessage()Fills the chat input but doesn't send itwindow.openai.sendFollowUpMessage
ui/update-model-contextAdd context for later turnsapp.updateModelContext()Shows up as a chat attachment—
ui/request-display-modeChange display modeapp.requestDisplayMode()Inline only; MCPJam's Copilot client also lists fullscreen—
ui/notifications/host-context-changedFollow theme changesapp.onhostcontextchangedSupported—
Handle a cancelled tool callapp.ontoolcancelledSupported—
Send debug logsapp.sendLog()Supported—

Our task board's UI talks to Copilot only through SDK helpers, so the same UI bundle can also render in other clients that support MCP Apps.

Part 1: Set up the task board

The task board from the Claude tutorial runs in Copilot as is. Both clients implement the MCP Apps standard, so there's no Copilot-specific code to write.

The server, task-board, keeps three tasks in memory and listens at http://127.0.0.1:3000/mcp. It splits its tools the way the Claude tutorial explains:

  • Two data tools. list_tasks returns the tasks, optionally filtered by status, and complete_task marks one done by taskId. Neither has a UI, so a data call never re-renders the board. Both repeat the tasks in content as JSON text, so the model has IDs and titles to pass to show_task_board even in a client that doesn't give it structuredContent.
  • One render tool. show_task_board is the only tool linked to the UI resource, ui://task-board/v1.html. It renders the tasks it receives as a React board, with a Done button on each row that calls complete_task.

This part gets that code running and makes the one change Copilot calls for: telling your tools apart from Copilot's own.

Before you start

Make sure you have:

  • Node.js 22+ (node --version). Both the app and MCPJam run on it.
  • npm, which ships with Node.
  • Optional: VS Code with the GitHub Copilot Chat extension, if you want to confirm the app in Copilot (step 9).

MCPJam doesn't need a GitHub account. For the check in Copilot, any Copilot plan works, but on Business and Enterprise an admin has to turn on the MCP servers in Copilot policy first.

1. Get the task board running

Copy the Claude version, build the UI, and start the server:

Scroll code horizontally →

git clone https://github.com/MCPJam/tutorials.git
cp -r tutorials/task-board-claude task-board

cd task-board/web && npm install && npm run build
cd ../server && npm install && npm start

You should see Task board MCP server: http://127.0.0.1:3000/mcp in the terminal.

Terminal running npm start in the task-board-copilot server directory, which prints Task board MCP server: http://127.0.0.1:3000/mcp

2. Tell the model what the board is for

The Claude version never says what kind of tasks the board holds. That works when the board's tools are the only task tools the model sees. Copilot's agent mode adds its own #todos tool, listed in Copilot's tools reference, for tracking the agent's progress through a request. So "Show me my open tasks" could reasonably go to either list_tasks or #todos. In server/src/server.ts, add what the board holds to the server instructions:

Scroll code horizontally →

       instructions:
-        "Call list_tasks to get tasks. To show tasks visually, call list_tasks first, then pass its tasks to show_task_board.",

+        "The task board holds the user's project tasks. Call list_tasks to get them. To show tasks visually, call list_tasks first, then pass its tasks to show_task_board.",

Then to the list_tasks description:

Scroll code horizontally →

       description:
-        "Use this when the user wants to find or review their tasks. Returns tasks with id, title, and done status.",

+        "Use this when the user wants to find or review tasks on their task board. Returns tasks with id, title, and done status. Not for the agent's own todo list.",

And to the complete_task description:

Scroll code horizontally →

-      description: "Use this when the user wants to mark a task as done.",

+      description: "Use this when the user wants to mark a task on their task board as done.",

Stop the server and run npm start again so it serves the updated descriptions.

Copilot supports server instructions, so the model sees them along with your tool descriptions. Copilot also shows the descriptions to the user, in the tools picker and in the confirmation dialog.

The annotations don't change, but Copilot uses two of them to decide what the user sees. It shows title in the Chat view when the tool runs, and it runs a tool marked readOnlyHint: true without asking. Every other tool gets a confirmation dialog first, where the user can also edit the arguments the model chose. So list_tasks and show_task_board run without a prompt, and complete_task shows the user the task ID to approve.

Part 2: Test and debug with MCPJam

MCPJam Inspector is an open-source dev tool for MCP servers and MCP Apps. It renders your UI the way a real client would, lets you select Copilot as the client, and shows the JSON-RPC traffic that the chat hides.

You'll start by calling the tools directly, without a model. Then you'll check which tools a model picks, and finish with a check in GitHub Copilot.

3. Launch MCPJam and connect

Leave the server running. In a new terminal do:

npx @mcpjam/inspector@latest
Terminal running npx @mcpjam/inspector@latest, showing the MCPJam logo, the MCPJam Inspector is ready message, and the local URL Inspector opened in the browser

MCPJam requires Node.js 22+. If you'd rather not use the terminal, install the desktop app or run MCPJam from Docker.

In the Servers tab, add an HTTP server at http://127.0.0.1:3000/mcp.

Swipe to inspect the full diagram →

MCPJam Add MCP Server dialog with the Copilot client selected, server name Task board, connection type HTTP, URL http://127.0.0.1:3000/mcp, and No Authentication

You should see three things in the server info view:

  • The server initialized.
  • Your instructions from step 2, shown verbatim.
  • All three tools listed with the schemas and annotations you wrote.

Swipe to inspect the full diagram →

MCPJam Servers tab showing the Task board v1.0.0 server connected at http://127.0.0.1:3000/mcp, with a Supported by 4 badge and a JSON-RPC log on the right listing tools/list and resources/read requests

Swipe to inspect the full diagram →

Task board server overview dialog in MCPJam showing server name task-board, MCP protocol version 2026-07-28, transport streamable-http, capabilities Tools and Resources, and the server instructions that begin The task board holds the user's project tasks

Swipe to inspect the full diagram →

Task board Tools tab in MCPJam listing list_tasks and complete_task with their descriptions, and the list_tasks annotations readOnlyHint true, destructiveHint false, and openWorldHint false

If you don't, a connection error almost always means the server isn't running or is on a different port. Missing tools mean their registerTool calls never ran, so check createServer().

4. Render the UI

Go to Playground, select Copilot as the client, open the Tools tab, and invoke show_task_board manually. The tool's inputSchema is { tasks: z.array(TaskSchema) }, so the input is an object with a tasks key, not a bare array:

Scroll code horizontally →

{
  "tasks": [
    { "id": "t1", "title": "Write the launch post", "done": false },
    { "id": "t2", "title": "Review the pricing page", "done": false },
    { "id": "t3", "title": "Book the team offsite", "done": true }
  ]
}

Swipe to inspect the full diagram →

MCPJam Playground with the Copilot client selected, rendering the show_task_board result inline: Write the launch post and Review the pricing page each with a Done button, and Book the team offsite crossed out, with the JSON-RPC log on the right

You should see the board render immediately in the widget emulator. Start with manual calls rather than chat, since that takes the model out of the picture while you check the UI. Each tool result also has panels where you can inspect the raw input, output, and errors.

With Copilot selected, MCPJam renders the board inline in Copilot's frame, up to 768 pixels wide and 400 pixels tall, with the dark theme Copilot reports. Check two things:

  1. The board fits the frame. All three rows should show without clipping or an inner scrollbar. Shrink the preview below 768 pixels too: titles should wrap and nothing should overflow sideways.
  1. The board reads well in both themes. Start in dark, then toggle to light. Text, borders, and the Done button should stay readable in each. The Claude markup styles itself with host CSS variables such as --color-text-primary, so if something loses contrast, the markup is reading a variable that was never set. Apply the host's variables with useHostStyles(app, app?.getHostContext()) right after useApp, which also re-applies them on each theme change, and give each variable a fallback, such as var(--color-text-primary, CanvasText), for any the host doesn't send.

If nothing shows up, check these in order:

  1. Does the tool's resourceUri exactly match the registered URI?
  1. Is the MIME type text/html;profile=mcp-app?
  1. Does web/dist/component.js exist? The server rereads it on every request, so a rebuild takes effect without a restart.
  1. Did the UI connect to the client? A tool call that succeeds with nothing rendered usually means useApp (or app.connect()) never ran, or the root element has zero height.
  1. What do the error panel and your browser's DevTools console say?

5. Interact with the UI and watch the messages

Click Done on "Write the launch post", then switch to the Trace view and select the complete_task request row. MCPJam shows the JSON-RPC messages between the client, your UI, and your server, so you can follow the click end to end:

  1. The UI sends tools/call for complete_task.
  2. The request reaches your server.
  3. The server responds with the updated tasks.
  4. The task gets a strikethrough.

Earlier in the trace, you should also see the handshake: ui/initialize when the UI connects, then ui/notifications/tool-input and ui/notifications/tool-result when the client delivers the tool call. Copilot's chat shows none of these messages. MCPJam's Trace view puts them in order alongside your server's traffic, so you can follow a click from the UI to your server and back without adding any logging to your UI.

If something breaks, the first missing step tells you where to look:

  • No ui/initialize in the trace: the app never connected. Check useApp or app.connect().
  • No tools/call in the trace: check whether the click handler is firing.
  • The call appears, but the server never sees it: check the tool name and arguments.
  • The server answers but the UI doesn't update: check how the UI handles the response.

Next, click a task to select it. The border highlight is React state, so it lasts until Copilot renders the board again. That's expected. If a selection must survive, keep it on the server or send it to the model with app.updateModelContext().

6. Check your CSP

A Content Security Policy (CSP) controls which domains your embedded UI can connect to or load resources from. Copilot enforces the policy your resource declares in _meta.ui.csp, and blocks anything outside it:

  • connectDomains covers fetch and XHR requests.
  • resourceDomains covers images, fonts, and other resources.
  • frameDomains covers content embedded in nested iframes.

Our board makes no external requests, so its lists are empty. In larger apps, common issues include:

  • an image bucket or API host you forgot to list
  • a script loaded at runtime instead of bundled
  • a font CDN. The board uses the host's font variable, so it doesn't need one

MCPJam's CSP testing shows what gets blocked. In the host context toolbar, set CSP mode to Strict (the default is Permissive), and a badge counts the blocked requests. Declare only the domains you use, so anyone reviewing your server can see exactly what the UI contacts. In Copilot, blocked requests show up as CSP errors in the console that Developer: Toggle Developer Tools opens.

7. Run fixed-input tool calls

Chat-driven testing is nondeterministic, so the same prompt can produce different arguments across runs. With fixed-input calls, you invoke each tool directly with arguments you choose. You can also pass deliberately invalid inputs to test error handling, so cover edge cases, missing IDs, and empty results.

In Copilot, invalid input can also come from the user. The confirmation dialog for complete_task lets the user edit the taskId the model chose, so a typo in that field reaches your server as-is. The isError message is what the model, and then the user, reads next.

These expectations assume the three-task fixture in server/src/server.ts. Tasks live in memory, so the click in step 5 already marked t1 done. Restart the server first to reset them:

Fixed-input tool call tests

Swipe to see all columns →

ToolInputExpected
list_tasks{}All three tasks
list_tasks{ "status": "done" }Only "Book the team offsite"
list_tasks{ "status": "archived" }Schema validation error
complete_task{ "taskId": "t2" }Updated list with t2 done
complete_task{ "taskId": "nope" }isError: true with a clear message
complete_task{}Schema validation error
show_task_board{ "tasks": [] }"Showing 0 tasks."

Compare each output against your declared schema.

Add one more case: a large result. Whatever a tool puts in content stays in the model's context, so a large result keeps using context on every later turn of the session. Paginate long lists, or return identifiers and let the UI fetch details with app.callServerTool().

If a future version of a tool returns images, check where they end up. In MCPJam's Copilot profile, the model can see images returned as direct content, embedded resources, or linked resources, but Copilot doesn't place tool-result media in the app surface. Draw images inside your UI instead, and list their host in resourceDomains.

If a test catches a bug, replay the same call with mcpjam tools call from the MCPJam CLI, so you can run the case automatically in future tests.

8. Test how the model uses your tools

Steps 4 through 7 verify that your tools work. This step verifies that a model reading your descriptions picks the right tool at the right time. In Playground, select Copilot as the client, try a few prompts, then open Trace to see each step from prompt to response:

  • "Show me my open tasks" should call list_tasks, then show_task_board.
  • "Mark the pricing page task as done" should call complete_task with t2, which means the model first needs the IDs from list_tasks.
  • "What's a good productivity method?" shouldn't call anything.

If the model skips list_tasks or fires tools when it shouldn't, look at your tool descriptions, server instructions, and parameter names before touching your handlers.

Then pick another model from the model picker in the chat input and run the same prompts. A description that steers one model to call list_tasks first may not steer another.

These runs include only your three tools. MCPJam doesn't load Copilot's built-in tools, so it can't show whether the model picks list_tasks over #todos. You'll check that in step 9.

9. Confirm it in GitHub Copilot

MCPJam's Copilot client is an emulation. To see the board in GitHub Copilot itself, we'll use the GitHub Copilot Chat extension in VS Code. VS Code renders MCP Apps inline in chat and runs the MCP client on your machine, so it connects to your local server directly, with no tunnel and no deploy.

This is also where you confirm the description change from step 2. VS Code is the first place your tools run next to the #todos tool that change targets.

Add the server to Copilot:

  1. Open the Command Palette and run MCP: Add Server.
  2. Choose HTTP (HTTP or Server-Sent Events) and enter http://127.0.0.1:3000/mcp.
  3. Enter task-board as the server ID, then select Global > Copilot Global.

This writes the entry to ~/.copilot/mcp-config.json, a portable file with a top-level mcpServers key that other Copilot tools also read. The older .vscode/mcp.json format uses servers instead, and the guided flow now labels it deprecated.

Open the Chat view in agent mode, pick a model, and ask "Show me my open tasks." You should see the List tasks and Show task board calls run without a confirmation prompt, because both are marked readOnlyHint: true, and the board render inline under them. Then check the behavior that only Copilot can show you:

  • The confirmation dialog. Ask "Mark the pricing page task as done." Copilot stops before complete_task and shows the taskId it's about to send. Approve it, and the model's reply should reflect the updated list.
  • The competing tool. If the model reaches for #todos instead of your tools, sharpen the server instructions and tool descriptions from step 2, then rerun the prompts from step 8. Users can also type # in the chat input and pick your tool explicitly.
  • Your editor's themes. Run Preferences: Color Theme and switch between a light and a dark theme with the board on screen. The board should stay readable in each. Try a high contrast theme too, since the host context reports only light or dark.

If the server doesn't start or a call fails, run MCP: List Servers, select task-board, and choose Show Output for the server log. For errors inside the UI, open Developer: Toggle Developer Tools and check the console.

On Copilot Business or Enterprise, nothing from your server appears until an admin enables the MCP servers in Copilot policy. If the server shows up for you but not for a colleague, check that first.

Pre-release checklist

Before you share the server, confirm each of these. Each item names the step that covers it.

  • The board renders in MCPJam's Copilot client, fits 768 × 400 pixels, and reads well in both themes (step 4).
  • The trace shows ui/initialize and the full complete_task round trip (step 5).
  • Strict CSP mode reports no blocked requests (step 6).
  • Every fixed-input call returns what the table in step 7 expects.
  • The test prompts call the right tools in the right order with more than one model (step 8).
  • In Copilot, the model picks your tools over #todos, complete_task asks for confirmation, and the board stays readable in your editor's themes (step 9).
  • If the UI changed since your last release, the resource URI is bumped (v1.html → v2.html). Clients cache the UI by its URI, so an unchanged URI can keep serving the old bundle.

What else MCPJam can do

This guide used the local Inspector, but that's only part of the platform. Once your app works locally, MCPJam covers what happens when real users and real releases get involved:

  • Evals: turn workflows like "Show me my open tasks" from step 8 into repeatable test suites. You can re-run them across your chosen clients and models and track pass rate, latency, token usage, and tool calls over time.
  • CI/CD gates: run those evals, along with protocol conformance and OAuth and security checks, on every pull request. If a change drops the pass rate below your threshold, the merge is blocked.
  • Swarms: describe your users, and MCPJam generates personas and goals, then runs simulated users through your server in parallel across Copilot and the other clients you choose. For the task board we just built, that could be a manager reviewing open work, a teammate closing out tasks, and someone asking for a task that doesn't exist. You can compare how each client behaves side by side.
  • User Testing: share a preview link with real testers, including guests, and collect per-turn ratings as they use your app. MCPJam turns those sessions into findings and insights, so you can measure sentiment and spot usability gaps before launch.

All of this works with local servers through the desktop app, the CLI, or the SDK. So the fixed-input cases from step 7 and the model tests from step 8 can grow into a regression suite that runs on every release.

What MCPJam can't tell you

One thing still needs GitHub Copilot itself: its chat UI. The tool confirmation dialog, the chat input that sendMessage() fills, and the attachment a context update becomes are all drawn by VS Code, outside your app's iframe. MCPJam traces the ui/message and ui/update-model-context calls behind the last two, but only Copilot shows what the user sees.

So do one final pass in Copilot before you share the server. By then, you've fixed what MCPJam can catch, so this pass only needs to cover the chat UI.

Start testing your GitHub Copilot MCP Apps

Clone the example repo and launch MCPJam with:

npx @mcpjam/inspector@latest

FAQ

How do I create an MCP App for GitHub Copilot?

Build an MCP server with the official SDK. Define focused tools with schemas and annotations, register a ui:// resource with registerAppResource(), and link a tool to it through _meta.ui.resourceUri. Serve it over Streamable HTTP, conventionally at /mcp, then add it to Copilot with MCP: Add Server and call it from Copilot in agent mode.

Which Copilot plans support MCP Apps?

On Copilot Free, Pro, and Pro+, you can add MCP servers yourself. On Copilot Business and Enterprise, an admin has to enable the MCP servers in Copilot policy first. MCPJam needs no Copilot plan at all: it renders the UI, calls tools, and traces messages locally, and for model testing it provides hosted models or uses your own API credentials.

Do I need a tunnel to test GitHub Copilot MCP Apps?

No. Copilot and MCPJam both connect to your server on localhost. You need a public HTTPS URL only when you share the server with people who can't reach your machine.

Why isn't my MCP App rendering in Copilot?

Call the render tool in MCPJam's Playground with Copilot selected. If the UI renders there, the problem is in your Copilot setup: check the server log under MCP: List Servers > Show Output, and on Business or Enterprise, the MCP servers in Copilot policy. If it doesn't render, the problem is in your server or UI, and the checklist in step 4 walks through the causes.

Can I use MCPJam's hosted app with a local server?

No. The hosted app only accepts HTTPS URLs. Use npx @mcpjam/inspector@latest or the desktop app instead.