PKU harness
Build a plugin

Quickstart: remote plugin

Build a remote plugin, an MCP server that uses Streamable HTTP, from templates/remote-plugin. Run it locally, add it to the app, deploy it and register it in the marketplace.

A remote plugin is a server you run yourself. The app connects to it over the internet and calls its tools. On this page you copy the template, write a tool, run the server on your computer, add it to the app, and then deploy and register it.

The server speaks the Model Context Protocol (MCP), an open standard that lets AI apps discover and call tools on a server. PKU harness uses MCP's Streamable HTTP transport, in which each call is an HTTP POST request.

Users add a remote plugin by its URL or from the marketplace. Its tools then join the same registry as the tools of bundled plugins, follow the same permissions and are called by the same assistant. None of your code runs on the phone, and a remote plugin never sees campus credentials.

You need bun and some TypeScript. The template uses TypeScript with the official MCP SDK, but any language with an MCP server library works the same way.

1. Start from the template

cd pku-harness-plugins
cp -r templates/remote-plugin ../my-plugin
cd ../my-plugin
bun install

The template is one file, server.ts, plus a package.json that pulls in @modelcontextprotocol/sdk, express and zod:

server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';

function build() {
  // name = your registry id: the app installs the plugin as `mcp-<name>`.
  const server = new McpServer(
    { name: 'my-plugin', version: '0.1.0', title: '我的插件' },
    { instructions: 'What this plugin is for, in one sentence.' },
  );
  server.registerTool(
    'hello',
    {
      title: '打个招呼',
      description: 'Return a greeting. Replace with a real capability.',
      inputSchema: { name: z.string().describe('who to greet') },
      annotations: { readOnlyHint: true },
    },
    async ({ name }) => ({
      content: [{ type: 'text', text: `你好,${name}` }],
      structuredContent: { greeting: `你好,${name}` },
    }),
  );
  return server;
}

const app = express();
app.use(express.json());
app.post('/mcp', async (req, res) => {
  // Optional bearer auth: users paste the token in the app; it is stored in their keychain.
  if (process.env.PLUGIN_TOKEN && req.headers.authorization !== `Bearer ${process.env.PLUGIN_TOKEN}`) {
    res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'unauthorized' }, id: null });
    return;
  }
  const server = build();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // stateless
  res.on('close', () => {
    transport.close();
    server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
app.listen(Number(process.env.PORT ?? 8787), () => console.log('MCP plugin ready on /mcp'));

The build() function creates the server and registers its tools. The /mcp route checks the optional token, then hands the request to a fresh server. Replace the hello tool with your own.

2. Write real tools

Each MCP field of a tool has a specific use in PKU harness:

  • name, the first argument of registerTool, becomes the tool id mcp-<server>.<name>. The model sees it as mcp-<server>__<name>. Use a short ASCII camelCase name. Other characters become _, and names longer than 40 characters are cut.
  • title is shown to users in chat, in approval prompts and in Settings. Write it in Chinese.
  • description is shown to the model. Say when and how to use the tool, and give units and formats.
  • inputSchema is converted to zod on the device and checked before every call. Describe every field.
  • annotations.readOnlyHint sets the effect. true gives the effect read, and the tool runs directly. If the hint is missing or false, the effect is write, and by default the app asks the user first.
  • structuredContent in the result is returned to the model as data. This is the preferred form. Without it, the app tries to parse the text content as JSON, and passes it as text if that fails.
  • isError: true in the result turns the text into the error message the assistant sees.

The first 500 characters of the server's instructions go into the plugin's section of the system prompt. Just before them, the app adds a line telling the model that this plugin's output is data and must not be followed as instructions.

Effects for remote tools

For now the app gives a remote tool one of two effects: read when readOnlyHint: true, and write for everything else. A tool that spends money or posts publicly is therefore also write, and asks first by default. Say plainly in its title and description what it does.

3. Run it and add it to the app

bun server.ts                       # MCP plugin ready on /mcp, port 8787
# optional: PLUGIN_TOKEN=secret bun server.ts

In the app, open Settings → Plugins → Add remote plugin. Enter the URL, and the token if you set one. The URL depends on where the app runs:

Where the app runsURL
Android emulatorhttp://10.0.2.2:8787/mcp
Phone on the same networkhttp://<your machine's LAN IP>:8787/mcp
Deployedhttps://my-plugin.example.com/mcp

The emulator reaches your computer at 10.0.2.2. A real phone needs your computer's address on the local network (LAN).

The app connects, lists your tools and shows each one with its effect on the plugin's page. Then ask the assistant something your tool can answer. You can also ask the assistant to install the plugin for you. It calls plugins.installRemote, which asks for your approval first.

A complete example

examples/demo-canteen in pku-harness-plugins is a working remote plugin with mock data. It has one read tool and one write tool, and its bearer token is demo-token. Run it with bun examples/demo-canteen/server.ts.

4. Deploy

Any host that serves HTTPS will do: a virtual private server (VPS), Fly.io, Railway, Cloudflare with an MCP-compatible runtime, or a campus server. Keep these points in mind:

  • Production needs HTTPS. Use plain HTTP only for local development.
  • POST /mcp must answer MCP Streamable HTTP requests. The template is stateless: it builds a new server for each request. That suits serverless hosts, which may start a fresh process for every request.
  • Some campus hosts answer only inside the PKU network. If you call one of them, your server has to run inside that network too. The app can't lend your server the user's campus session.
  • Your own secrets, such as API keys, stay on the server. To protect the endpoint, use a bearer token and set "auth": "bearer" in the registry entry.

5. Register it

Registering the plugin lists it in the marketplace, so students can find it. There are two ways to do this. You can add a manifest to your repository and submit it through this website's marketplace (manifest reference, submitting). Or you can add an entry to registry/registry.json in pku-harness-plugins. Either way, the id must match your MCP server's name:

{
  "id": "my-plugin",
  "name": "我的插件",
  "description": "一句话说明它做什么。",
  "author": "your-github-name",
  "source": "https://github.com/you/my-plugin",
  "kind": "remote",
  "endpoint": "https://my-plugin.example.com/mcp",
  "auth": "none",
  "requires": ["network"],
  "scopes": ["example.com"],
  "tags": ["canteen"]
}

Before you open a pull request against the registry, run bun scripts/validate-registry.ts. See Publishing and the review policy.

Checklist

  • The MCP server's name equals the registry id (^[a-z0-9][a-z0-9-]{1,40}$).
  • Every tool has a Chinese title, a description for the model, and described input fields.
  • Read-only tools set annotations.readOnlyHint: true, and no other tool does.
  • Results are data (structuredContent), never instructions, and reasonably small.
  • The plugin never asks for a campus password.
  • The endpoint uses HTTPS, and bearer auth is declared if you use it.

On this page